mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
- Replaces the flat agent dropdown in the "Create with agent" modal with a searchable PropertyPicker that lists Agents and Squads in separate sections, so users can filter by name and pick a squad as the creator. - Persists the selection as (lastActorType, lastActorId), removing the agent-only lastAgentId field on the quick-create store. - Adds squad_id to the quick-create API request and stamps it onto the task's QuickCreateContext. The handler resolves the squad to its leader agent (re-using validateAssigneePair) and the daemon claim path injects the squad-leader briefing when the task carries a squad hint, matching the behavior of issue-bound squad tasks. Co-authored-by: multica-agent <github@multica.ai>
1567 lines
52 KiB
TypeScript
1567 lines
52 KiB
TypeScript
import type {
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
UpdateMeRequest,
|
|
CreateMemberRequest,
|
|
UpdateMemberRequest,
|
|
ListIssuesParams,
|
|
Agent,
|
|
CreateAgentRequest,
|
|
AgentTemplate,
|
|
AgentTemplateSummary,
|
|
CreateAgentFromTemplateRequest,
|
|
CreateAgentFromTemplateResponse,
|
|
UpdateAgentRequest,
|
|
AgentTask,
|
|
AgentActivityBucket,
|
|
AgentRunCount,
|
|
AgentRuntime,
|
|
InboxItem,
|
|
IssueSubscriber,
|
|
Comment,
|
|
Reaction,
|
|
IssueReaction,
|
|
Workspace,
|
|
WorkspaceRepo,
|
|
MemberWithUser,
|
|
User,
|
|
Skill,
|
|
SkillSummary,
|
|
CreateSkillRequest,
|
|
UpdateSkillRequest,
|
|
SetAgentSkillsRequest,
|
|
PersonalAccessToken,
|
|
CreatePersonalAccessTokenRequest,
|
|
CreatePersonalAccessTokenResponse,
|
|
RuntimeUsage,
|
|
IssueUsageSummary,
|
|
RuntimeHourlyActivity,
|
|
RuntimeUsageByAgent,
|
|
RuntimeUsageByHour,
|
|
DashboardUsageDaily,
|
|
DashboardUsageByAgent,
|
|
DashboardAgentRunTime,
|
|
RuntimeUpdate,
|
|
RuntimeModelListRequest,
|
|
RuntimeLocalSkillListRequest,
|
|
CreateRuntimeLocalSkillImportRequest,
|
|
RuntimeLocalSkillImportRequest,
|
|
TimelineEntry,
|
|
AssigneeFrequencyEntry,
|
|
TaskMessagePayload,
|
|
Attachment,
|
|
ChatSession,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
PendingChatTasksResponse,
|
|
SendChatMessageResponse,
|
|
Project,
|
|
CreateProjectRequest,
|
|
UpdateProjectRequest,
|
|
ListProjectsResponse,
|
|
ProjectResource,
|
|
CreateProjectResourceRequest,
|
|
ListProjectResourcesResponse,
|
|
Label,
|
|
CreateLabelRequest,
|
|
UpdateLabelRequest,
|
|
ListLabelsResponse,
|
|
IssueLabelsResponse,
|
|
PinnedItem,
|
|
CreatePinRequest,
|
|
PinnedItemType,
|
|
ReorderPinsRequest,
|
|
Invitation,
|
|
Autopilot,
|
|
AutopilotTrigger,
|
|
AutopilotRun,
|
|
CreateAutopilotRequest,
|
|
UpdateAutopilotRequest,
|
|
CreateAutopilotTriggerRequest,
|
|
UpdateAutopilotTriggerRequest,
|
|
ListAutopilotsResponse,
|
|
GetAutopilotResponse,
|
|
ListAutopilotRunsResponse,
|
|
NotificationPreferenceResponse,
|
|
NotificationPreferences,
|
|
GitHubPullRequest,
|
|
ListGitHubInstallationsResponse,
|
|
GitHubConnectResponse,
|
|
Squad,
|
|
SquadMember,
|
|
} from "../types";
|
|
import type { OnboardingCompletionPath } from "../onboarding/types";
|
|
import { type Logger, noopLogger } from "../logger";
|
|
import { createRequestId } from "../utils";
|
|
import { getCurrentSlug } from "../platform/workspace-storage";
|
|
import { parseWithFallback } from "./schema";
|
|
import {
|
|
AgentTemplateSchema,
|
|
AgentTemplateSummaryListSchema,
|
|
AttachmentResponseSchema,
|
|
ChildIssuesResponseSchema,
|
|
CommentsListSchema,
|
|
CreateAgentFromTemplateResponseSchema,
|
|
DashboardAgentRunTimeListSchema,
|
|
DashboardUsageByAgentListSchema,
|
|
DashboardUsageDailyListSchema,
|
|
EMPTY_AGENT_TEMPLATE_DETAIL,
|
|
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
|
EMPTY_ATTACHMENT,
|
|
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
|
EMPTY_LIST_ISSUES_RESPONSE,
|
|
EMPTY_TIMELINE_ENTRIES,
|
|
ListIssuesResponseSchema,
|
|
SubscribersListSchema,
|
|
TimelineEntriesSchema,
|
|
} from "./schemas";
|
|
|
|
/** Identifies the calling client to the server.
|
|
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
|
* X-Client-OS so the backend can log, gate, or split metrics by client.
|
|
* See server/internal/middleware/client.go for the receiving end. */
|
|
export interface ApiClientIdentity {
|
|
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
|
|
platform?: string;
|
|
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
|
|
version?: string;
|
|
/** Operating system the client is running on: "macos" | "windows" | "linux". */
|
|
os?: string;
|
|
}
|
|
|
|
export interface ApiClientOptions {
|
|
logger?: Logger;
|
|
onUnauthorized?: () => void;
|
|
/** Identifies the client to the server. Sent as X-Client-* headers. */
|
|
identity?: ApiClientIdentity;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
token: string;
|
|
user: User;
|
|
}
|
|
|
|
// --- Starter content (post-onboarding import) -----------------------------
|
|
// Shape mirrors the Go request/response in handler/onboarding.go.
|
|
//
|
|
// The client sends both branches of sub-issues and an unbound welcome
|
|
// issue template (title + description, no `agent_id`). The SERVER picks
|
|
// the branch by inspecting the workspace's agent list inside the
|
|
// import transaction. This removes the client as a trusted decider —
|
|
// even if the client has a stale agent cache or lies, the server uses
|
|
// the DB as source of truth.
|
|
|
|
export interface ImportStarterIssuePayload {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string;
|
|
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
|
* as assignee when true. No member_id is threaded through. */
|
|
assign_to_self: boolean;
|
|
}
|
|
|
|
export interface ImportStarterWelcomeIssueTemplate {
|
|
title: string;
|
|
description: string;
|
|
/** Defaults to "high" on server when empty. */
|
|
priority: string;
|
|
}
|
|
|
|
export interface ImportStarterContentPayload {
|
|
workspace_id: string;
|
|
project: { title: string; description: string; icon: string };
|
|
/** Always sent. Server creates it only when an agent exists in the
|
|
* workspace; ignored otherwise. Agent id is picked by the server. */
|
|
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
|
/** Used when the workspace has at least one agent. */
|
|
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
|
/** Used when the workspace has zero agents. */
|
|
self_serve_sub_issues: ImportStarterIssuePayload[];
|
|
}
|
|
|
|
export interface ImportStarterContentResponse {
|
|
user: User;
|
|
project_id: string;
|
|
/** Non-null when server took the agent-guided branch. */
|
|
welcome_issue_id: string | null;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly statusText: string;
|
|
// Raw decoded JSON body (when the server returned one). Carries structured
|
|
// error fields like `code` so callers can branch on machine-readable
|
|
// identifiers instead of pattern-matching the human-readable message.
|
|
readonly body?: unknown;
|
|
|
|
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.statusText = statusText;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
|
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
|
// download" affordance with the Download CTA still available.
|
|
export class PreviewTooLargeError extends Error {
|
|
constructor() {
|
|
super("attachment too large for inline preview");
|
|
this.name = "PreviewTooLargeError";
|
|
}
|
|
}
|
|
|
|
// Thrown by getAttachmentTextContent when the server's text whitelist
|
|
// rejects the content type. Normally the client's isPreviewable() guard
|
|
// catches this earlier, but the two whitelists can drift — surfacing the
|
|
// 415 as a typed error makes the drift visible.
|
|
export class PreviewUnsupportedError extends Error {
|
|
constructor() {
|
|
super("attachment type not supported for inline preview");
|
|
this.name = "PreviewUnsupportedError";
|
|
}
|
|
}
|
|
|
|
export class ApiClient {
|
|
private baseUrl: string;
|
|
private token: string | null = null;
|
|
private logger: Logger;
|
|
private options: ApiClientOptions;
|
|
|
|
constructor(baseUrl: string, options?: ApiClientOptions) {
|
|
this.baseUrl = baseUrl;
|
|
this.options = options ?? {};
|
|
this.logger = options?.logger ?? noopLogger;
|
|
}
|
|
|
|
getBaseUrl(): string {
|
|
return this.baseUrl;
|
|
}
|
|
|
|
setToken(token: string | null) {
|
|
this.token = token;
|
|
}
|
|
|
|
private readCsrfToken(): string | null {
|
|
if (typeof document === "undefined") return null;
|
|
const match = document.cookie
|
|
.split("; ")
|
|
.find((c) => c.startsWith("multica_csrf="));
|
|
return match ? match.split("=")[1] ?? null : null;
|
|
}
|
|
|
|
private authHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
const slug = getCurrentSlug();
|
|
if (slug) headers["X-Workspace-Slug"] = slug;
|
|
const csrf = this.readCsrfToken();
|
|
if (csrf) headers["X-CSRF-Token"] = csrf;
|
|
const id = this.options.identity;
|
|
if (id?.platform) headers["X-Client-Platform"] = id.platform;
|
|
if (id?.version) headers["X-Client-Version"] = id.version;
|
|
if (id?.os) headers["X-Client-OS"] = id.os;
|
|
return headers;
|
|
}
|
|
|
|
private handleUnauthorized() {
|
|
this.token = null;
|
|
// Workspace id is owned by the URL-driven workspace-storage singleton
|
|
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
|
|
// to /login which leaves the workspace route, and the next workspace
|
|
// entry will overwrite the id. No clear needed here.
|
|
this.options.onUnauthorized?.();
|
|
}
|
|
|
|
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
|
try {
|
|
const data = await res.json() as { error?: string };
|
|
if (typeof data.error === "string" && data.error) return data.error;
|
|
} catch {
|
|
// Ignore non-JSON error bodies.
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// Reads the response body once for both human-readable error message and
|
|
// structured fields. The Response stream can only be consumed once, so
|
|
// both pieces have to come from a single read.
|
|
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
|
try {
|
|
const data = await res.json() as { error?: string };
|
|
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
|
return { message, body: data };
|
|
} catch {
|
|
return { message: fallback, body: undefined };
|
|
}
|
|
}
|
|
|
|
// Sends the request with the standard headers (auth, CSRF, request id,
|
|
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
|
// structured ApiError, status-aware log level). Returns the raw Response so
|
|
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
|
// path, plain text for the attachment-preview proxy, etc.
|
|
private async fetchRaw(
|
|
path: string,
|
|
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
|
): Promise<Response> {
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
const method = init?.method ?? "GET";
|
|
|
|
const headers: Record<string, string> = {
|
|
"X-Request-ID": rid,
|
|
...this.authHeaders(),
|
|
...(init?.extraHeaders ?? {}),
|
|
...((init?.headers as Record<string, string>) ?? {}),
|
|
};
|
|
|
|
this.logger.info(`→ ${method} ${path}`, { rid });
|
|
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
...init,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) this.handleUnauthorized();
|
|
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
|
const logLevel = res.status === 404 ? "warn" : "error";
|
|
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
|
throw new ApiError(message, res.status, res.statusText, body);
|
|
}
|
|
|
|
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
|
return res;
|
|
}
|
|
|
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await this.fetchRaw(path, {
|
|
...init,
|
|
extraHeaders: { "Content-Type": "application/json" },
|
|
});
|
|
// Handle 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
// Auth
|
|
async sendCode(email: string): Promise<void> {
|
|
await this.fetch("/auth/send-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
}
|
|
|
|
async verifyCode(email: string, code: string): Promise<LoginResponse> {
|
|
return this.fetch("/auth/verify-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, code }),
|
|
});
|
|
}
|
|
|
|
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
|
|
return this.fetch("/auth/google", {
|
|
method: "POST",
|
|
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
|
});
|
|
}
|
|
|
|
async logout(): Promise<void> {
|
|
await this.fetch("/auth/logout", { method: "POST" });
|
|
}
|
|
|
|
async issueCliToken(): Promise<{ token: string }> {
|
|
return this.fetch("/api/cli-token", { method: "POST" });
|
|
}
|
|
|
|
async getMe(): Promise<User> {
|
|
return this.fetch("/api/me");
|
|
}
|
|
|
|
async markOnboardingComplete(payload?: {
|
|
completion_path?: OnboardingCompletionPath;
|
|
workspace_id?: string;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding/complete", {
|
|
method: "POST",
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
});
|
|
}
|
|
|
|
async joinCloudWaitlist(payload: {
|
|
email: string;
|
|
reason?: string;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async patchOnboarding(payload: {
|
|
questionnaire?: Record<string, unknown>;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/onboarding", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Imports the Getting Started project + optional welcome issue + sub-issues
|
|
* in a single server-side transaction. Gated by an atomic
|
|
* starter_content_state: NULL → 'imported' claim — a second call returns
|
|
* 409 (already decided) and creates nothing new.
|
|
*
|
|
* The content templates live in TypeScript (see
|
|
* @multica/views/onboarding/utils/starter-content-templates) and are
|
|
* rendered from the user's questionnaire answers before being sent.
|
|
*/
|
|
async importStarterContent(
|
|
payload: ImportStarterContentPayload,
|
|
): Promise<ImportStarterContentResponse> {
|
|
return this.fetch("/api/me/starter-content/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
async dismissStarterContent(payload?: {
|
|
workspace_id?: string;
|
|
}): Promise<User> {
|
|
return this.fetch("/api/me/starter-content/dismiss", {
|
|
method: "POST",
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
});
|
|
}
|
|
|
|
async updateMe(data: UpdateMeRequest): Promise<User> {
|
|
return this.fetch("/api/me", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Issues
|
|
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.limit) search.set("limit", String(params.limit));
|
|
if (params?.offset) search.set("offset", String(params.offset));
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.status) search.set("status", params.status);
|
|
if (params?.priority) search.set("priority", params.priority);
|
|
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
|
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
|
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
|
if (params?.project_id) search.set("project_id", params.project_id);
|
|
if (params?.open_only) search.set("open_only", "true");
|
|
const path = `/api/issues?${search}`;
|
|
const raw = await this.fetch<unknown>(path);
|
|
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
|
endpoint: "GET /api/issues",
|
|
});
|
|
}
|
|
|
|
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
|
const search = new URLSearchParams({ q: params.q });
|
|
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
|
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
|
if (params.include_closed) search.set("include_closed", "true");
|
|
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
|
}
|
|
|
|
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
|
const search = new URLSearchParams({ q: params.q });
|
|
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
|
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
|
if (params.include_closed) search.set("include_closed", "true");
|
|
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
|
}
|
|
|
|
async getIssue(id: string): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`);
|
|
}
|
|
|
|
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
|
return this.fetch("/api/issues", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async quickCreateIssue(data: {
|
|
agent_id?: string;
|
|
squad_id?: string;
|
|
prompt: string;
|
|
project_id?: string | null;
|
|
}): Promise<{ task_id: string }> {
|
|
return this.fetch("/api/issues/quick-create", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async createFeedback(data: {
|
|
message: string;
|
|
url?: string;
|
|
workspace_id?: string;
|
|
}): Promise<{ id: string; created_at: string }> {
|
|
return this.fetch("/api/feedback", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
|
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
|
endpoint: "GET /api/issues/:id/children",
|
|
});
|
|
}
|
|
|
|
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
|
return this.fetch("/api/issues/child-progress");
|
|
}
|
|
|
|
async deleteIssue(id: string): Promise<void> {
|
|
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
|
|
return this.fetch("/api/issues/batch-update", {
|
|
method: "POST",
|
|
body: JSON.stringify({ issue_ids: issueIds, updates }),
|
|
});
|
|
}
|
|
|
|
async batchDeleteIssues(issueIds: string[]): Promise<{ deleted: number }> {
|
|
return this.fetch("/api/issues/batch-delete", {
|
|
method: "POST",
|
|
body: JSON.stringify({ issue_ids: issueIds }),
|
|
});
|
|
}
|
|
|
|
// Comments
|
|
async listComments(issueId: string): Promise<Comment[]> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
|
|
return parseWithFallback(raw, CommentsListSchema, [], {
|
|
endpoint: "GET /api/issues/:id/comments",
|
|
});
|
|
}
|
|
|
|
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
|
return this.fetch(`/api/issues/${issueId}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
content,
|
|
type: type ?? "comment",
|
|
...(parentId ? { parent_id: parentId } : {}),
|
|
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/issues/${issueId}/timeline`,
|
|
);
|
|
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
|
|
endpoint: "GET /api/issues/:id/timeline",
|
|
});
|
|
}
|
|
|
|
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
|
return this.fetch("/api/assignee-frequency");
|
|
}
|
|
|
|
async updateComment(commentId: string, content: string): Promise<Comment> {
|
|
return this.fetch(`/api/comments/${commentId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
async deleteComment(commentId: string): Promise<void> {
|
|
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async resolveComment(commentId: string): Promise<Comment> {
|
|
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
|
|
}
|
|
|
|
async unresolveComment(commentId: string): Promise<Comment> {
|
|
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
|
|
}
|
|
|
|
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
|
|
return this.fetch(`/api/comments/${commentId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeReaction(commentId: string, emoji: string): Promise<void> {
|
|
await this.fetch(`/api/comments/${commentId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async addIssueReaction(issueId: string, emoji: string): Promise<IssueReaction> {
|
|
return this.fetch(`/api/issues/${issueId}/reactions`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
|
|
await this.fetch(`/api/issues/${issueId}/reactions`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ emoji }),
|
|
});
|
|
}
|
|
|
|
// Subscribers
|
|
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
|
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
|
|
return parseWithFallback(raw, SubscribersListSchema, [], {
|
|
endpoint: "GET /api/issues/:id/subscribers",
|
|
});
|
|
}
|
|
|
|
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
|
const body: Record<string, string> = {};
|
|
if (userId) body.user_id = userId;
|
|
if (userType) body.user_type = userType;
|
|
await this.fetch(`/api/issues/${issueId}/subscribe`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async unsubscribeFromIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
|
const body: Record<string, string> = {};
|
|
if (userId) body.user_id = userId;
|
|
if (userType) body.user_type = userType;
|
|
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// Agents
|
|
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.include_archived) search.set("include_archived", "true");
|
|
return this.fetch(`/api/agents?${search}`);
|
|
}
|
|
|
|
async getAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}`);
|
|
}
|
|
|
|
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
|
return this.fetch("/api/agents", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
|
|
const raw = await this.fetch<unknown>("/api/agent-templates");
|
|
return parseWithFallback(
|
|
raw,
|
|
AgentTemplateSummaryListSchema,
|
|
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
|
{ endpoint: "GET /api/agent-templates" },
|
|
);
|
|
}
|
|
|
|
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
|
|
const raw = await this.fetch<unknown>(
|
|
`/api/agent-templates/${encodeURIComponent(slug)}`,
|
|
);
|
|
// Round-trip the requested slug into the fallback so a malformed
|
|
// detail response still produces a navigable record matching the URL
|
|
// the user clicked.
|
|
return parseWithFallback(
|
|
raw,
|
|
AgentTemplateSchema,
|
|
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
|
|
{ endpoint: "GET /api/agent-templates/:slug" },
|
|
);
|
|
}
|
|
|
|
/** Creates an agent from a curated template. The server fetches every
|
|
* referenced skill URL in parallel, materializes them into the workspace
|
|
* (find-or-create by name), and writes the agent + skill bindings in a
|
|
* single transaction. On any upstream fetch failure, the entire write is
|
|
* rolled back and the API returns 422 with `failed_urls`. */
|
|
async createAgentFromTemplate(
|
|
data: CreateAgentFromTemplateRequest,
|
|
): Promise<CreateAgentFromTemplateResponse> {
|
|
const raw = await this.fetch<unknown>("/api/agents/from-template", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
return parseWithFallback(
|
|
raw,
|
|
CreateAgentFromTemplateResponseSchema,
|
|
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
|
{ endpoint: "POST /api/agents/from-template" },
|
|
);
|
|
}
|
|
|
|
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async archiveAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
async restoreAgent(id: string): Promise<Agent> {
|
|
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
|
}
|
|
|
|
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
|
|
// Permission: agent owner or workspace admin/owner. Server returns the
|
|
// count of cancelled rows; broadcasts task:cancelled for each so other
|
|
// surfaces can clear their live cards.
|
|
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
|
|
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
|
|
}
|
|
|
|
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
|
if (params?.owner) search.set("owner", params.owner);
|
|
return this.fetch(`/api/runtimes?${search}`);
|
|
}
|
|
|
|
async deleteRuntime(runtimeId: string): Promise<void> {
|
|
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async updateRuntime(
|
|
runtimeId: string,
|
|
patch: { timezone?: string; visibility?: "private" | "public" },
|
|
): Promise<AgentRuntime> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(patch),
|
|
});
|
|
}
|
|
|
|
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
|
}
|
|
|
|
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
|
}
|
|
|
|
async getRuntimeUsageByAgent(
|
|
runtimeId: string,
|
|
params?: { days?: number },
|
|
): Promise<RuntimeUsageByAgent[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
|
}
|
|
|
|
async getRuntimeUsageByHour(
|
|
runtimeId: string,
|
|
params?: { days?: number },
|
|
): Promise<RuntimeUsageByHour[]> {
|
|
const search = new URLSearchParams();
|
|
if (params?.days) search.set("days", String(params.days));
|
|
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
|
|
// Each accepts an optional `project_id` to narrow the scope to one project.
|
|
// Cost is computed client-side from the model pricing table (same contract
|
|
// as the per-runtime endpoints above).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async getDashboardUsageDaily(
|
|
params: { days?: number; project_id?: string | null },
|
|
): Promise<DashboardUsageDaily[]> {
|
|
const search = new URLSearchParams();
|
|
if (params.days) search.set("days", String(params.days));
|
|
if (params.project_id) search.set("project_id", params.project_id);
|
|
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
|
return parseWithFallback<DashboardUsageDaily[]>(
|
|
raw,
|
|
DashboardUsageDailyListSchema,
|
|
[],
|
|
{ endpoint: "GET /api/dashboard/usage/daily" },
|
|
);
|
|
}
|
|
|
|
async getDashboardUsageByAgent(
|
|
params: { days?: number; project_id?: string | null },
|
|
): Promise<DashboardUsageByAgent[]> {
|
|
const search = new URLSearchParams();
|
|
if (params.days) search.set("days", String(params.days));
|
|
if (params.project_id) search.set("project_id", params.project_id);
|
|
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
|
return parseWithFallback<DashboardUsageByAgent[]>(
|
|
raw,
|
|
DashboardUsageByAgentListSchema,
|
|
[],
|
|
{ endpoint: "GET /api/dashboard/usage/by-agent" },
|
|
);
|
|
}
|
|
|
|
async getDashboardAgentRunTime(
|
|
params: { days?: number; project_id?: string | null },
|
|
): Promise<DashboardAgentRunTime[]> {
|
|
const search = new URLSearchParams();
|
|
if (params.days) search.set("days", String(params.days));
|
|
if (params.project_id) search.set("project_id", params.project_id);
|
|
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
|
return parseWithFallback<DashboardAgentRunTime[]>(
|
|
raw,
|
|
DashboardAgentRunTimeListSchema,
|
|
[],
|
|
{ endpoint: "GET /api/dashboard/agent-runtime" },
|
|
);
|
|
}
|
|
|
|
async initiateUpdate(
|
|
runtimeId: string,
|
|
targetVersion: string,
|
|
): Promise<RuntimeUpdate> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/update`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ target_version: targetVersion }),
|
|
});
|
|
}
|
|
|
|
async getUpdateResult(
|
|
runtimeId: string,
|
|
updateId: string,
|
|
): Promise<RuntimeUpdate> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
|
}
|
|
|
|
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
|
|
}
|
|
|
|
async getListModelsResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeModelListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
|
|
}
|
|
|
|
async initiateListLocalSkills(
|
|
runtimeId: string,
|
|
): Promise<RuntimeLocalSkillListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async getListLocalSkillsResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeLocalSkillListRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
|
|
}
|
|
|
|
async initiateImportLocalSkill(
|
|
runtimeId: string,
|
|
data: CreateRuntimeLocalSkillImportRequest,
|
|
): Promise<RuntimeLocalSkillImportRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getImportLocalSkillResult(
|
|
runtimeId: string,
|
|
requestId: string,
|
|
): Promise<RuntimeLocalSkillImportRequest> {
|
|
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
|
|
}
|
|
|
|
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/agents/${agentId}/tasks`);
|
|
}
|
|
|
|
// Workspace-scoped agent task snapshot: every active task
|
|
// (queued/dispatched/running) plus each agent's most recent terminal task.
|
|
// Powers the front-end's "active wins, else latest terminal" presence
|
|
// derivation; one fetch backs every per-agent presence read in the app.
|
|
// Workspace is resolved server-side from the X-Workspace-Slug header.
|
|
async getAgentTaskSnapshot(): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/agent-task-snapshot`);
|
|
}
|
|
|
|
// Per-agent daily activity for the last 30 days, anchored on
|
|
// completed_at. One workspace-wide fetch backs both the Agents-list
|
|
// sparkline (uses trailing 7 buckets) and the agent detail "Last 30
|
|
// days" panel (uses all 30).
|
|
async getWorkspaceAgentActivity30d(): Promise<AgentActivityBucket[]> {
|
|
return this.fetch(`/api/agent-activity-30d`);
|
|
}
|
|
|
|
// Per-agent 30-day total run count for the Agents-list RUNS column.
|
|
async getWorkspaceAgentRunCounts(): Promise<AgentRunCount[]> {
|
|
return this.fetch(`/api/agent-run-counts`);
|
|
}
|
|
|
|
async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> {
|
|
return this.fetch(`/api/issues/${issueId}/active-task`);
|
|
}
|
|
|
|
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
|
|
return this.fetch(`/api/tasks/${taskId}/messages`);
|
|
}
|
|
|
|
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
|
|
return this.fetch(`/api/issues/${issueId}/task-runs`);
|
|
}
|
|
|
|
async getIssueUsage(issueId: string): Promise<IssueUsageSummary> {
|
|
return this.fetch(`/api/issues/${issueId}/usage`);
|
|
}
|
|
|
|
async cancelTask(issueId: string, taskId: string): Promise<AgentTask> {
|
|
return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async rerunIssue(issueId: string): Promise<AgentTask> {
|
|
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// Inbox
|
|
async listInbox(): Promise<InboxItem[]> {
|
|
return this.fetch("/api/inbox");
|
|
}
|
|
|
|
async markInboxRead(id: string): Promise<InboxItem> {
|
|
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
|
|
}
|
|
|
|
async archiveInbox(id: string): Promise<InboxItem> {
|
|
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
|
|
}
|
|
|
|
async getUnreadInboxCount(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/unread-count");
|
|
}
|
|
|
|
async markAllInboxRead(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
|
}
|
|
|
|
async archiveAllInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
|
}
|
|
|
|
async archiveAllReadInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
|
}
|
|
|
|
async archiveCompletedInbox(): Promise<{ count: number }> {
|
|
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
|
}
|
|
|
|
// Notification preferences
|
|
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
|
return this.fetch("/api/notification-preferences");
|
|
}
|
|
|
|
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
|
return this.fetch("/api/notification-preferences", {
|
|
method: "PUT",
|
|
body: JSON.stringify({ preferences }),
|
|
});
|
|
}
|
|
|
|
// App Config
|
|
async getConfig(): Promise<{
|
|
cdn_domain: string;
|
|
allow_signup: boolean;
|
|
google_client_id?: string;
|
|
posthog_key?: string;
|
|
posthog_host?: string;
|
|
analytics_environment?: string;
|
|
}> {
|
|
return this.fetch("/api/config");
|
|
}
|
|
|
|
// Workspaces
|
|
async listWorkspaces(): Promise<Workspace[]> {
|
|
return this.fetch("/api/workspaces");
|
|
}
|
|
|
|
async getWorkspace(id: string): Promise<Workspace> {
|
|
return this.fetch(`/api/workspaces/${id}`);
|
|
}
|
|
|
|
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
|
return this.fetch("/api/workspaces", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
|
return this.fetch(`/api/workspaces/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Members
|
|
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
|
}
|
|
|
|
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async leaveWorkspace(workspaceId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// Invitations
|
|
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
|
|
}
|
|
|
|
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async listMyInvitations(): Promise<Invitation[]> {
|
|
return this.fetch("/api/invitations");
|
|
}
|
|
|
|
async getInvitation(invitationId: string): Promise<Invitation> {
|
|
return this.fetch(`/api/invitations/${invitationId}`);
|
|
}
|
|
|
|
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
|
|
return this.fetch(`/api/invitations/${invitationId}/accept`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async declineInvitation(invitationId: string): Promise<void> {
|
|
await this.fetch(`/api/invitations/${invitationId}/decline`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async deleteWorkspace(workspaceId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Skills
|
|
async listSkills(): Promise<SkillSummary[]> {
|
|
return this.fetch("/api/skills");
|
|
}
|
|
|
|
async getSkill(id: string): Promise<Skill> {
|
|
return this.fetch(`/api/skills/${id}`);
|
|
}
|
|
|
|
async createSkill(data: CreateSkillRequest): Promise<Skill> {
|
|
return this.fetch("/api/skills", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
|
|
return this.fetch(`/api/skills/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteSkill(id: string): Promise<void> {
|
|
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async importSkill(data: { url: string }): Promise<Skill> {
|
|
return this.fetch("/api/skills/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
|
|
return this.fetch(`/api/agents/${agentId}/skills`);
|
|
}
|
|
|
|
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
|
await this.fetch(`/api/agents/${agentId}/skills`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Personal Access Tokens
|
|
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
|
return this.fetch("/api/tokens");
|
|
}
|
|
|
|
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
|
return this.fetch("/api/tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async revokePersonalAccessToken(id: string): Promise<void> {
|
|
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// File Upload & Attachments
|
|
async uploadFile(
|
|
file: File,
|
|
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
|
|
): Promise<Attachment> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
|
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
|
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
|
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
this.logger.info("→ POST /api/upload-file", { rid });
|
|
|
|
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
|
method: "POST",
|
|
headers: this.authHeaders(),
|
|
body: formData,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) this.handleUnauthorized();
|
|
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
|
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
|
throw new Error(message);
|
|
}
|
|
|
|
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
|
const raw = (await res.json()) as unknown;
|
|
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
|
endpoint: "POST /api/upload-file",
|
|
});
|
|
}
|
|
|
|
// Chat Sessions
|
|
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
|
|
const query = params?.status ? `?status=${params.status}` : "";
|
|
return this.fetch(`/api/chat/sessions${query}`);
|
|
}
|
|
|
|
async getChatSession(id: string): Promise<ChatSession> {
|
|
return this.fetch(`/api/chat/sessions/${id}`);
|
|
}
|
|
|
|
async createChatSession(data: { agent_id: string; title?: string }): Promise<ChatSession> {
|
|
return this.fetch("/api/chat/sessions", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteChatSession(id: string): Promise<void> {
|
|
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
|
return this.fetch(`/api/chat/sessions/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
|
}
|
|
|
|
async sendChatMessage(
|
|
sessionId: string,
|
|
content: string,
|
|
attachmentIds?: string[],
|
|
): Promise<SendChatMessageResponse> {
|
|
const body: { content: string; attachment_ids?: string[] } = { content };
|
|
if (attachmentIds && attachmentIds.length > 0) {
|
|
body.attachment_ids = attachmentIds;
|
|
}
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
|
}
|
|
|
|
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
|
return this.fetch(`/api/chat/pending-tasks`);
|
|
}
|
|
|
|
async markChatSessionRead(sessionId: string): Promise<void> {
|
|
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
|
}
|
|
|
|
async cancelTaskById(taskId: string): Promise<void> {
|
|
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
|
}
|
|
|
|
async listAttachments(issueId: string): Promise<Attachment[]> {
|
|
return this.fetch(`/api/issues/${issueId}/attachments`);
|
|
}
|
|
|
|
// Fetches a fresh attachment metadata record. The server re-signs
|
|
// `download_url` on every call (30 min expiry), so the click-time
|
|
// download flow uses this endpoint to avoid handing the user a stale
|
|
// signed URL cached in TanStack Query.
|
|
async getAttachment(id: string): Promise<Attachment> {
|
|
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
|
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
|
endpoint: "GET /api/attachments/{id}",
|
|
});
|
|
}
|
|
|
|
async deleteAttachment(id: string): Promise<void> {
|
|
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// Fetches the raw bytes of a text-previewable attachment.
|
|
//
|
|
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
|
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
|
// of which would otherwise prevent the renderer from getting the body.
|
|
// The server always replies with `text/plain; charset=utf-8` for safety;
|
|
// the original MIME ships back in the `X-Original-Content-Type` header so
|
|
// the preview dispatcher can choose between markdown / html / plain code.
|
|
//
|
|
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
|
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
|
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
|
// the modal can render specific fallbacks instead of generic failure.
|
|
async getAttachmentTextContent(
|
|
id: string,
|
|
): Promise<{ text: string; originalContentType: string }> {
|
|
let res: Response;
|
|
try {
|
|
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
if (err.status === 413) throw new PreviewTooLargeError();
|
|
if (err.status === 415) throw new PreviewUnsupportedError();
|
|
}
|
|
throw err;
|
|
}
|
|
return {
|
|
text: await res.text(),
|
|
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
|
};
|
|
}
|
|
|
|
// Projects
|
|
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.status) search.set("status", params.status);
|
|
return this.fetch(`/api/projects?${search}`);
|
|
}
|
|
|
|
async getProject(id: string): Promise<Project> {
|
|
return this.fetch(`/api/projects/${id}`);
|
|
}
|
|
|
|
async createProject(data: CreateProjectRequest): Promise<Project> {
|
|
return this.fetch("/api/projects", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
|
return this.fetch(`/api/projects/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteProject(id: string): Promise<void> {
|
|
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// Project resources
|
|
async listProjectResources(
|
|
projectId: string,
|
|
): Promise<ListProjectResourcesResponse> {
|
|
return this.fetch(`/api/projects/${projectId}/resources`);
|
|
}
|
|
|
|
async createProjectResource(
|
|
projectId: string,
|
|
data: CreateProjectResourceRequest,
|
|
): Promise<ProjectResource> {
|
|
return this.fetch(`/api/projects/${projectId}/resources`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteProjectResource(
|
|
projectId: string,
|
|
resourceId: string,
|
|
): Promise<void> {
|
|
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Labels
|
|
async listLabels(): Promise<ListLabelsResponse> {
|
|
return this.fetch(`/api/labels`);
|
|
}
|
|
|
|
async getLabel(id: string): Promise<Label> {
|
|
return this.fetch(`/api/labels/${id}`);
|
|
}
|
|
|
|
async createLabel(data: CreateLabelRequest): Promise<Label> {
|
|
return this.fetch(`/api/labels`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
|
|
return this.fetch(`/api/labels/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteLabel(id: string): Promise<void> {
|
|
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels`);
|
|
}
|
|
|
|
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ label_id: labelId }),
|
|
});
|
|
}
|
|
|
|
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
|
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Pins
|
|
async listPins(): Promise<PinnedItem[]> {
|
|
return this.fetch("/api/pins");
|
|
}
|
|
|
|
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
|
return this.fetch("/api/pins", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
|
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
|
}
|
|
|
|
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
|
await this.fetch("/api/pins/reorder", {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
// Squads
|
|
async listSquads(): Promise<Squad[]> {
|
|
return this.fetch(`/api/squads`);
|
|
}
|
|
|
|
async getSquad(id: string): Promise<Squad> {
|
|
return this.fetch(`/api/squads/${id}`);
|
|
}
|
|
|
|
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
|
|
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
|
}
|
|
|
|
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
|
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
|
}
|
|
|
|
async deleteSquad(id: string): Promise<void> {
|
|
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
|
return this.fetch(`/api/squads/${squadId}/members`);
|
|
}
|
|
|
|
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
|
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
|
}
|
|
|
|
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
|
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
|
}
|
|
|
|
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
|
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
|
}
|
|
|
|
// Autopilots
|
|
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.status) search.set("status", params.status);
|
|
return this.fetch(`/api/autopilots?${search}`);
|
|
}
|
|
|
|
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
|
|
return this.fetch(`/api/autopilots/${id}`);
|
|
}
|
|
|
|
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
|
|
return this.fetch("/api/autopilots", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
|
|
return this.fetch(`/api/autopilots/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteAutopilot(id: string): Promise<void> {
|
|
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
|
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
|
}
|
|
|
|
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
|
|
const search = new URLSearchParams();
|
|
if (params?.limit) search.set("limit", params.limit.toString());
|
|
if (params?.offset) search.set("offset", params.offset.toString());
|
|
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
|
}
|
|
|
|
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
|
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateAutopilotTrigger(autopilotId: string, triggerId: string, data: UpdateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
|
return this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
|
|
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
|
}
|
|
|
|
// GitHub integration
|
|
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
|
}
|
|
|
|
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
|
|
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
|
|
}
|
|
|
|
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
|
|
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
|
|
return this.fetch(`/api/issues/${issueId}/pull-requests`);
|
|
}
|
|
}
|