import type { Issue, CreateIssueRequest, UpdateIssueRequest, GroupedIssuesResponse, ListIssuesResponse, SearchIssuesResponse, SearchProjectsResponse, UpdateMeRequest, CreateMemberRequest, UpdateMemberRequest, ListIssuesParams, ListGroupedIssuesParams, Agent, CreateAgentRequest, AgentTemplate, AgentTemplateSummary, CreateAgentFromTemplateRequest, CreateAgentFromTemplateResponse, UpdateAgentRequest, AgentEnvResponse, UpdateAgentEnvRequest, AgentTask, AgentActivityBucket, AgentRunCount, AgentRuntime, InboxItem, IssueSubscriber, Comment, CommentTriggerPreview, Reaction, IssueReaction, Workspace, WorkspaceRepo, MemberWithUser, User, Skill, SkillSummary, CreateSkillRequest, UpdateSkillRequest, SetAgentSkillsRequest, PersonalAccessToken, CreatePersonalAccessTokenRequest, CreatePersonalAccessTokenResponse, RuntimeUsage, IssueUsageSummary, RuntimeHourlyActivity, RuntimeUsageByAgent, RuntimeUsageByHour, DashboardUsageDaily, DashboardUsageByAgent, DashboardAgentRunTime, DashboardRunTimeDaily, RuntimeUpdate, RuntimeModelListRequest, RuntimeLocalSkillListRequest, CreateRuntimeLocalSkillImportRequest, RuntimeLocalSkillImportRequest, TimelineEntry, AssigneeFrequencyEntry, TaskMessagePayload, Attachment, ChatSession, ChatMessage, ChatMessagesPage, ChatPendingTask, PendingChatTasksResponse, SendChatMessageResponse, CancelTaskResponse, Project, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse, ProjectResource, CreateProjectResourceRequest, UpdateProjectResourceRequest, ListProjectResourcesResponse, Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse, PinnedItem, CreatePinRequest, PinnedItemType, ReorderPinsRequest, Invitation, Autopilot, AutopilotTrigger, AutopilotRun, CreateAutopilotRequest, UpdateAutopilotRequest, CreateAutopilotTriggerRequest, UpdateAutopilotTriggerRequest, ListAutopilotsResponse, GetAutopilotResponse, ListAutopilotRunsResponse, ListWebhookDeliveriesResponse, WebhookDelivery, NotificationPreferenceResponse, NotificationPreferences, GitHubPullRequest, ListGitHubInstallationsResponse, GitHubConnectResponse, ListLarkInstallationsResponse, BeginLarkInstallResponse, LarkInstallStatusResponse, RedeemLarkBindingTokenResponse, Squad, SquadMember, SquadMemberStatusListResponse, BillingBalance, BillingTransactionsPage, BillingBatchesPage, BillingTopupsPage, BillingPriceTier, CreateBillingCheckoutSessionRequest, CreateBillingCheckoutSessionResponse, BillingCheckoutSessionStatus, CreateBillingPortalSessionResponse, } from "../types"; import type { OnboardingCompletionPath } from "../onboarding/types"; import type { CloudRuntimeNode, CreateCloudRuntimeNodeRequest, ListCloudRuntimeNodesParams, } from "../runtimes/cloud-runtime"; import { type Logger, noopLogger } from "../logger"; import { createRequestId } from "../utils"; import { getCurrentSlug } from "../platform/workspace-storage"; import { parseWithFallback } from "./schema"; import { AgentTemplateSchema, AgentTemplateSummaryListSchema, AttachmentResponseSchema, CancelTaskResponseSchema, ChildIssuesResponseSchema, CommentsListSchema, CommentTriggerPreviewSchema, CloudRuntimeNodeListSchema, CloudRuntimeNodeSchema, CreateAgentFromTemplateResponseSchema, DashboardAgentRunTimeListSchema, DashboardRunTimeDailyListSchema, DashboardUsageByAgentListSchema, DashboardUsageDailyListSchema, EMPTY_AGENT_TEMPLATE_DETAIL, EMPTY_AGENT_TEMPLATE_SUMMARY_LIST, EMPTY_APP_CONFIG, EMPTY_ATTACHMENT, EMPTY_CLOUD_RUNTIME_NODE, EMPTY_CLOUD_RUNTIME_NODE_LIST, EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE, EMPTY_GROUPED_ISSUES_RESPONSE, EMPTY_LIST_ISSUES_RESPONSE, EMPTY_SQUAD, EMPTY_SQUAD_LIST, EMPTY_SQUAD_MEMBER_STATUS_LIST, EMPTY_TIMELINE_ENTRIES, EMPTY_USER, EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE, EMPTY_WEBHOOK_DELIVERY, AppConfigSchema, type AppConfigResponse, GroupedIssuesResponseSchema, ListAutopilotsResponseSchema, EMPTY_LIST_AUTOPILOTS_RESPONSE, ListIssuesResponseSchema, ListWebhookDeliveriesResponseSchema, RuntimeHourlyActivityListSchema, RuntimeUsageByAgentListSchema, RuntimeUsageByHourListSchema, RuntimeUsageListSchema, SquadSchema, SquadListSchema, SquadMemberStatusListResponseSchema, SubscribersListSchema, TimelineEntriesSchema, UserSchema, WebhookDeliveryResponseSchema, BillingBalanceSchema, BillingTransactionsPageSchema, BillingBatchesPageSchema, BillingTopupsPageSchema, BillingPriceTierListSchema, CreateBillingCheckoutSessionResponseSchema, BillingCheckoutSessionStatusSchema, CreateBillingPortalSessionResponseSchema, EMPTY_BILLING_BALANCE, EMPTY_BILLING_TRANSACTIONS_PAGE, EMPTY_BILLING_BATCHES_PAGE, EMPTY_BILLING_TOPUPS_PAGE, EMPTY_BILLING_PRICE_TIER_LIST, EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE, EMPTY_BILLING_CHECKOUT_SESSION_STATUS, EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE, EMPTY_CANCEL_TASK_RESPONSE, } 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; } 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 { const headers: Record = {}; 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 { 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` // path, plain text for the attachment-preview proxy, etc. private async fetchRaw( path: string, init?: RequestInit & { extraHeaders?: Record }, ): Promise { const rid = createRequestId(); const start = Date.now(); const method = init?.method ?? "GET"; const headers: Record = { "X-Request-ID": rid, ...this.authHeaders(), ...(init?.extraHeaders ?? {}), ...((init?.headers as Record) ?? {}), }; 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(path: string, init?: RequestInit): Promise { 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; } // Auth async sendCode(email: string): Promise { await this.fetch("/auth/send-code", { method: "POST", body: JSON.stringify({ email }), }); } async verifyCode(email: string, code: string): Promise { return this.fetch("/auth/verify-code", { method: "POST", body: JSON.stringify({ email, code }), }); } async googleLogin(code: string, redirectUri: string): Promise { return this.fetch("/auth/google", { method: "POST", body: JSON.stringify({ code, redirect_uri: redirectUri }), }); } async logout(): Promise { await this.fetch("/auth/logout", { method: "POST" }); } async issueCliToken(): Promise<{ token: string }> { return this.fetch("/api/cli-token", { method: "POST" }); } async getMe(): Promise { const raw = await this.fetch("/api/me"); return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "GET /api/me", }); } async markOnboardingComplete(payload?: { completion_path?: OnboardingCompletionPath; workspace_id?: string; }): Promise { const raw = await this.fetch("/api/me/onboarding/complete", { method: "POST", body: payload ? JSON.stringify(payload) : undefined, }); return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "POST /api/me/onboarding/complete", }); } async joinCloudWaitlist(payload: { email: string; reason?: string; }): Promise { const raw = await this.fetch("/api/me/onboarding/cloud-waitlist", { method: "POST", body: JSON.stringify(payload), }); return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "POST /api/me/onboarding/cloud-waitlist", }); } async patchOnboarding(payload: { questionnaire?: Record; }): Promise { const raw = await this.fetch("/api/me/onboarding", { method: "PATCH", body: JSON.stringify(payload), }); return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "PATCH /api/me/onboarding", }); } async updateMe(data: UpdateMeRequest): Promise { const raw = await this.fetch("/api/me", { method: "PATCH", body: JSON.stringify(data), }); return parseWithFallback(raw, UserSchema, EMPTY_USER, { endpoint: "PATCH /api/me", }); } // Issues async listIssues(params?: ListIssuesParams): Promise { 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?.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params?.metadata && Object.keys(params.metadata).length > 0) { search.set("metadata", JSON.stringify(params.metadata)); } if (params?.open_only) search.set("open_only", "true"); if (params?.scheduled) search.set("scheduled", "true"); if (params?.date_field) search.set("date_field", params.date_field); if (params?.date_start) search.set("date_start", params.date_start); if (params?.date_end) search.set("date_end", params.date_end); if (params?.sort_by) search.set("sort", params.sort_by); if (params?.sort_direction) search.set("direction", params.sort_direction); const path = `/api/issues?${search}`; const raw = await this.fetch(path); return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, { endpoint: "GET /api/issues", }); } async listGroupedIssues(params: ListGroupedIssuesParams): Promise { const search = new URLSearchParams({ group_by: params.group_by }); 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.statuses?.length) search.set("statuses", params.statuses.join(",")); if (params.priorities?.length) search.set("priorities", params.priorities.join(",")); if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(",")); 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.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params.metadata && Object.keys(params.metadata).length > 0) { search.set("metadata", JSON.stringify(params.metadata)); } if (params.assignee_filters?.length) { search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(",")); } if (params.include_no_assignee) search.set("include_no_assignee", "true"); if (params.creator_filters?.length) { search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(",")); } if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(",")); if (params.include_no_project) search.set("include_no_project", "true"); if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(",")); if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type); if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id); if (params.date_field) search.set("date_field", params.date_field); if (params.date_start) search.set("date_start", params.date_start); if (params.date_end) search.set("date_end", params.date_end); if (params.sort_by) search.set("sort", params.sort_by); if (params.sort_direction) search.set("direction", params.sort_direction); const raw = await this.fetch(`/api/issues/grouped?${search}`); return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, { endpoint: "GET /api/issues/grouped", }); } async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise { 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 { 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 { return this.fetch(`/api/issues/${id}`); } async createIssue(data: CreateIssueRequest): Promise { 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; parent_issue_id?: string | null; attachment_ids?: string[]; }): 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 { return this.fetch(`/api/issues/${id}`, { method: "PUT", body: JSON.stringify(data), }); } async listChildIssues(id: string): Promise<{ issues: Issue[] }> { const raw = await this.fetch(`/api/issues/${id}/children`); return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, { endpoint: "GET /api/issues/:id/children", }); } /** Batched variant — returns children for multiple parents in one request. * Avoids an N-request fan-out in Swimlane (one per visible parent lane). * parentIds must be non-empty; pass a sorted, deduplicated list so the * React Query cache key is stable across renders. */ async listChildrenByParents(parentIds: string[]): Promise<{ issues: Issue[] }> { const raw = await this.fetch( `/api/issues/children?parent_ids=${parentIds.join(",")}`, ); return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, { endpoint: "GET /api/issues/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 { 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 { const raw = await this.fetch(`/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[], suppressAgentIds?: string[], ): Promise { 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 } : {}), ...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}), }), }); } async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise { const raw = await this.fetch(`/api/issues/${issueId}/comments/trigger-preview`, { method: "POST", body: JSON.stringify({ content, ...(parentId ? { parent_id: parentId } : {}), ...(editingCommentId ? { editing_comment_id: editingCommentId } : {}), }), }); return parseWithFallback(raw, CommentTriggerPreviewSchema, { agents: [] }, { endpoint: "POST /api/issues/:id/comments/trigger-preview", }); } async listTimeline(issueId: string): Promise { const raw = await this.fetch( `/api/issues/${issueId}/timeline`, ); return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, { endpoint: "GET /api/issues/:id/timeline", }); } async getAssigneeFrequency(): Promise { return this.fetch("/api/assignee-frequency"); } async updateComment(commentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise { return this.fetch(`/api/comments/${commentId}`, { method: "PUT", body: JSON.stringify({ content, attachment_ids: attachmentIds, ...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}), }), }); } async deleteComment(commentId: string): Promise { await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" }); } async resolveComment(commentId: string): Promise { return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" }); } async unresolveComment(commentId: string): Promise { return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" }); } async addReaction(commentId: string, emoji: string): Promise { return this.fetch(`/api/comments/${commentId}/reactions`, { method: "POST", body: JSON.stringify({ emoji }), }); } async removeReaction(commentId: string, emoji: string): Promise { await this.fetch(`/api/comments/${commentId}/reactions`, { method: "DELETE", body: JSON.stringify({ emoji }), }); } async addIssueReaction(issueId: string, emoji: string): Promise { return this.fetch(`/api/issues/${issueId}/reactions`, { method: "POST", body: JSON.stringify({ emoji }), }); } async removeIssueReaction(issueId: string, emoji: string): Promise { await this.fetch(`/api/issues/${issueId}/reactions`, { method: "DELETE", body: JSON.stringify({ emoji }), }); } // Subscribers async listIssueSubscribers(issueId: string): Promise { const raw = await this.fetch(`/api/issues/${issueId}/subscribers`); return parseWithFallback(raw, SubscribersListSchema, [], { endpoint: "GET /api/issues/:id/subscribers", }); } async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise { const body: Record = {}; 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 { const body: Record = {}; 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 { 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 { return this.fetch(`/api/agents/${id}`); } async createAgent(data: CreateAgentRequest): Promise { return this.fetch("/api/agents", { method: "POST", body: JSON.stringify(data), }); } async listAgentTemplates(): Promise { const raw = await this.fetch("/api/agent-templates"); return parseWithFallback( raw, AgentTemplateSummaryListSchema, EMPTY_AGENT_TEMPLATE_SUMMARY_LIST, { endpoint: "GET /api/agent-templates" }, ); } async getAgentTemplate(slug: string): Promise { const raw = await this.fetch( `/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 { const raw = await this.fetch("/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 { return this.fetch(`/api/agents/${id}`, { method: "PUT", body: JSON.stringify(data), }); } async archiveAgent(id: string): Promise { return this.fetch(`/api/agents/${id}/archive`, { method: "POST" }); } /** * Returns the plaintext `custom_env` map for an agent. Owner/admin * only; calls from agent-actor sessions get a 403. Every successful * call writes an `agent_env_revealed` activity_log row server-side. * MUL-2600. */ async getAgentEnv(id: string): Promise { return this.fetch(`/api/agents/${id}/env`); } /** * Replaces an agent's `custom_env` wholesale. Values equal to * `"****"` are preserved server-side (the **** guard) so a partial * UI edit doesn't overwrite real secrets with the masked * placeholder. Owner/admin only; agent actors get a 403. Every * successful call writes an `agent_env_updated` activity_log row. * MUL-2600. */ async updateAgentEnv(id: string, data: UpdateAgentEnvRequest): Promise { return this.fetch(`/api/agents/${id}/env`, { method: "PUT", body: JSON.stringify(data), }); } async restoreAgent(id: string): Promise { 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 { 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 listCloudRuntimeNodes( params?: ListCloudRuntimeNodesParams, ): Promise { const search = new URLSearchParams(); if (params?.limit !== undefined) search.set("limit", String(params.limit)); if (params?.offset !== undefined) search.set("offset", String(params.offset)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-runtime/nodes${query ? `?${query}` : ""}`, ); return parseWithFallback( raw, CloudRuntimeNodeListSchema, EMPTY_CLOUD_RUNTIME_NODE_LIST, { endpoint: "GET /api/cloud-runtime/nodes" }, ); } async createCloudRuntimeNode( data: CreateCloudRuntimeNodeRequest, ): Promise { const res = await this.fetchRaw("/api/cloud-runtime/nodes", { method: "POST", body: JSON.stringify(data), extraHeaders: { "Content-Type": "application/json" }, }); const raw = await res.json() as unknown; return parseWithFallback( raw, CloudRuntimeNodeSchema, EMPTY_CLOUD_RUNTIME_NODE, { endpoint: "POST /api/cloud-runtime/nodes" }, ); } async deleteCloudRuntimeNode(instanceId: string): Promise { await this.fetchRaw("/api/cloud-runtime/nodes", { method: "DELETE", body: JSON.stringify({ instance_id: instanceId }), extraHeaders: { "Content-Type": "application/json" }, }); } // --------------------------------------------------------------------- // Cloud Billing — proxies to multica-cloud /api/v1/billing/*. The // multica-api server stamps X-User-ID and forwards bytes; everything // here is upstream-shaped. See packages/core/types/billing.ts for the // response field documentation. // --------------------------------------------------------------------- async getCloudBillingBalance(): Promise { const raw = await this.fetch("/api/cloud-billing/balance"); return parseWithFallback(raw, BillingBalanceSchema, EMPTY_BILLING_BALANCE, { endpoint: "GET /api/cloud-billing/balance", }); } async listCloudBillingTransactions( params?: { page?: number; page_size?: number }, ): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/transactions${query ? `?${query}` : ""}`, ); return parseWithFallback( raw, BillingTransactionsPageSchema, EMPTY_BILLING_TRANSACTIONS_PAGE, { endpoint: "GET /api/cloud-billing/transactions" }, ); } async listCloudBillingBatches( params?: { page?: number; page_size?: number }, ): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/batches${query ? `?${query}` : ""}`, ); return parseWithFallback( raw, BillingBatchesPageSchema, EMPTY_BILLING_BATCHES_PAGE, { endpoint: "GET /api/cloud-billing/batches" }, ); } async listCloudBillingTopups( params?: { page?: number; page_size?: number }, ): Promise { const search = new URLSearchParams(); if (params?.page !== undefined) search.set("page", String(params.page)); if (params?.page_size !== undefined) search.set("page_size", String(params.page_size)); const query = search.toString(); const raw = await this.fetch( `/api/cloud-billing/topups${query ? `?${query}` : ""}`, ); return parseWithFallback( raw, BillingTopupsPageSchema, EMPTY_BILLING_TOPUPS_PAGE, { endpoint: "GET /api/cloud-billing/topups" }, ); } async listCloudBillingPriceTiers(): Promise { const raw = await this.fetch("/api/cloud-billing/price-tiers"); return parseWithFallback( raw, BillingPriceTierListSchema, EMPTY_BILLING_PRICE_TIER_LIST, { endpoint: "GET /api/cloud-billing/price-tiers" }, ); } async createCloudBillingCheckoutSession( data: CreateBillingCheckoutSessionRequest, ): Promise { const res = await this.fetchRaw("/api/cloud-billing/checkout-sessions", { method: "POST", body: JSON.stringify(data), extraHeaders: { "Content-Type": "application/json" }, }); const raw = (await res.json()) as unknown; return parseWithFallback( raw, CreateBillingCheckoutSessionResponseSchema, EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE, { endpoint: "POST /api/cloud-billing/checkout-sessions" }, ); } async getCloudBillingCheckoutSession( sessionId: string, ): Promise { // Stripe session ids are `cs_` so they're URL-safe by // construction; encodeURIComponent is paranoia for the case where a // future Stripe format change adds a non-alphanumeric character. The // server has its own allow-list rejection for unsafe ids. const raw = await this.fetch( `/api/cloud-billing/checkout-sessions/${encodeURIComponent(sessionId)}`, ); return parseWithFallback( raw, BillingCheckoutSessionStatusSchema, EMPTY_BILLING_CHECKOUT_SESSION_STATUS, { endpoint: "GET /api/cloud-billing/checkout-sessions/{sessionId}" }, ); } async createCloudBillingPortalSession(): Promise { const res = await this.fetchRaw("/api/cloud-billing/portal-sessions", { method: "POST", // Body is intentionally absent — the upstream endpoint requires no // payload today. fetchRaw with no body skips the Content-Type // default; that's fine because there's nothing to declare. }); const raw = (await res.json()) as unknown; return parseWithFallback( raw, CreateBillingPortalSessionResponseSchema, EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE, { endpoint: "POST /api/cloud-billing/portal-sessions" }, ); } async deleteRuntime(runtimeId: string): Promise { await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" }); } // Cascade variant of deleteRuntime. The strict DELETE refuses with // structured 409 (`code: "runtime_has_active_agents"`, body carries the // blocking agents) when active agents are bound; the front-end then opens // the cascade-mode confirmation dialog and submits the user-confirmed // active agent set here. Server compares the snapshot to the live set // inside the transaction and refuses with `code: "runtime_delete_plan_changed"` // (same shape, fresh `active_agents`) if they don't match — caller should // re-render the agent list and force the user to re-confirm. async archiveAgentsAndDeleteRuntime( runtimeId: string, expectedActiveAgentIds: string[], ): Promise<{ status: string; agents_archived: number; tasks_cancelled: number }> { return this.fetch(`/api/runtimes/${runtimeId}/archive-agents-and-delete`, { method: "POST", body: JSON.stringify({ expected_active_agent_ids: expectedActiveAgentIds }), }); } async updateRuntime( runtimeId: string, patch: { visibility?: "private" | "public" }, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}`, { method: "PATCH", body: JSON.stringify(patch), }); } async getRuntimeUsage( runtimeId: string, params?: { days?: number; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params?.days) search.set("days", String(params.days)); // `tz` drives the calendar-day boundary for the trend chart (Viewing // layer). Caller-supplied; the backend falls back to user.timezone / // UTC if omitted. if (params?.tz) search.set("tz", params.tz); const raw = await this.fetch( `/api/runtimes/${runtimeId}/usage?${search}`, ); return parseWithFallback(raw, RuntimeUsageListSchema, [], { endpoint: "GET /api/runtimes/:id/usage", }); } async getRuntimeTaskActivity( runtimeId: string, params?: { tz?: string }, ): Promise { // Hour-of-day heatmap follows the viewer's tz, like the other reports on // this page. Pass the viewer's IANA zone so the server buckets correctly. const search = new URLSearchParams(); if (params?.tz) search.set("tz", params.tz); const raw = await this.fetch( `/api/runtimes/${runtimeId}/activity?${search}`, ); return parseWithFallback( raw, RuntimeHourlyActivityListSchema, [], { endpoint: "GET /api/runtimes/:id/activity" }, ); } async getRuntimeUsageByAgent( runtimeId: string, params?: { days?: number; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params?.days) search.set("days", String(params.days)); if (params?.tz) search.set("tz", params.tz); const raw = await this.fetch( `/api/runtimes/${runtimeId}/usage/by-agent?${search}`, ); return parseWithFallback( raw, RuntimeUsageByAgentListSchema, [], { endpoint: "GET /api/runtimes/:id/usage/by-agent" }, ); } async getRuntimeUsageByHour( runtimeId: string, params?: { days?: number; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params?.days) search.set("days", String(params.days)); if (params?.tz) search.set("tz", params.tz); const raw = await this.fetch( `/api/runtimes/${runtimeId}/usage/by-hour?${search}`, ); return parseWithFallback( raw, RuntimeUsageByHourListSchema, [], { endpoint: "GET /api/runtimes/:id/usage/by-hour" }, ); } // --------------------------------------------------------------------------- // 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; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); if (params.tz) search.set("tz", params.tz); const raw = await this.fetch(`/api/dashboard/usage/daily?${search}`); return parseWithFallback( raw, DashboardUsageDailyListSchema, [], { endpoint: "GET /api/dashboard/usage/daily" }, ); } async getDashboardUsageByAgent( params: { days?: number; project_id?: string | null; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); if (params.tz) search.set("tz", params.tz); const raw = await this.fetch(`/api/dashboard/usage/by-agent?${search}`); return parseWithFallback( raw, DashboardUsageByAgentListSchema, [], { endpoint: "GET /api/dashboard/usage/by-agent" }, ); } async getDashboardAgentRunTime( params: { days?: number; project_id?: string | null; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); // `tz` aligns the "last N days" cutoff with the viewer's calendar, // matching the per-agent token card. if (params.tz) search.set("tz", params.tz); const raw = await this.fetch(`/api/dashboard/agent-runtime?${search}`); return parseWithFallback( raw, DashboardAgentRunTimeListSchema, [], { endpoint: "GET /api/dashboard/agent-runtime" }, ); } async getDashboardRunTimeDaily( params: { days?: number; project_id?: string | null; tz?: string }, ): Promise { const search = new URLSearchParams(); if (params.days) search.set("days", String(params.days)); if (params.project_id) search.set("project_id", params.project_id); // `tz` cuts the day buckets in the viewer's calendar so Time / Tasks // align with the Cost / Tokens charts. if (params.tz) search.set("tz", params.tz); const raw = await this.fetch(`/api/dashboard/runtime/daily?${search}`); return parseWithFallback( raw, DashboardRunTimeDailyListSchema, [], { endpoint: "GET /api/dashboard/runtime/daily" }, ); } async initiateUpdate( runtimeId: string, targetVersion: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/update`, { method: "POST", body: JSON.stringify({ target_version: targetVersion }), }); } async getUpdateResult( runtimeId: string, updateId: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`); } async initiateListModels(runtimeId: string): Promise { return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" }); } async getListModelsResult( runtimeId: string, requestId: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`); } async initiateListLocalSkills( runtimeId: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, { method: "POST", }); } async getListLocalSkillsResult( runtimeId: string, requestId: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`); } async initiateImportLocalSkill( runtimeId: string, data: CreateRuntimeLocalSkillImportRequest, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, { method: "POST", body: JSON.stringify(data), }); } async getImportLocalSkillResult( runtimeId: string, requestId: string, ): Promise { return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`); } async listAgentTasks(agentId: string): Promise { 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 { 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 { return this.fetch(`/api/agent-activity-30d`); } // Per-agent 30-day total run count for the Agents-list RUNS column. async getWorkspaceAgentRunCounts(): Promise { 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 { return this.fetch(`/api/tasks/${taskId}/messages`); } async listTasksByIssue(issueId: string): Promise { return this.fetch(`/api/issues/${issueId}/task-runs`); } async getIssueUsage(issueId: string): Promise { return this.fetch(`/api/issues/${issueId}/usage`); } async cancelTask(issueId: string, taskId: string): Promise { return this.fetch(`/api/issues/${issueId}/tasks/${taskId}/cancel`, { method: "POST", }); } async rerunIssue(issueId: string, taskId?: string): Promise { return this.fetch(`/api/issues/${issueId}/rerun`, { method: "POST", body: JSON.stringify(taskId ? { task_id: taskId } : {}), }); } // Inbox async listInbox(): Promise { return this.fetch("/api/inbox"); } async markInboxRead(id: string): Promise { return this.fetch(`/api/inbox/${id}/read`, { method: "POST" }); } async archiveInbox(id: string): Promise { 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 // // `workspaceSlug` overrides the default `X-Workspace-Slug` header (which // follows the active workspace) so a caller can read a SPECIFIC workspace's // preferences — e.g. honoring the mute setting of the workspace an inbox // notification came from while the user is viewing a different one (#3766). async getNotificationPreferences(workspaceSlug?: string): Promise { return this.fetch( "/api/notification-preferences", workspaceSlug ? { headers: { "X-Workspace-Slug": workspaceSlug } } : undefined, ); } async updateNotificationPreferences(preferences: NotificationPreferences): Promise { return this.fetch("/api/notification-preferences", { method: "PUT", body: JSON.stringify({ preferences }), }); } // App Config async getConfig(): Promise { const raw = await this.fetch("/api/config"); return parseWithFallback(raw, AppConfigSchema, EMPTY_APP_CONFIG, { endpoint: "GET /api/config", }); } // Workspaces async listWorkspaces(): Promise { return this.fetch("/api/workspaces"); } async getWorkspace(id: string): Promise { return this.fetch(`/api/workspaces/${id}`); } async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise { return this.fetch("/api/workspaces", { method: "POST", body: JSON.stringify(data), }); } async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record; repos?: WorkspaceRepo[]; issue_prefix?: string; avatar_url?: string }): Promise { return this.fetch(`/api/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(data), }); } // Members async listMembers(workspaceId: string): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members`); } async createMember(workspaceId: string, data: CreateMemberRequest): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members`, { method: "POST", body: JSON.stringify(data), }); } async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise { return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, { method: "PATCH", body: JSON.stringify(data), }); } async deleteMember(workspaceId: string, memberId: string): Promise { await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, { method: "DELETE", }); } async leaveWorkspace(workspaceId: string): Promise { await this.fetch(`/api/workspaces/${workspaceId}/leave`, { method: "POST", }); } // Invitations async listWorkspaceInvitations(workspaceId: string): Promise { return this.fetch(`/api/workspaces/${workspaceId}/invitations`); } async revokeInvitation(workspaceId: string, invitationId: string): Promise { await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, { method: "DELETE", }); } async listMyInvitations(): Promise { return this.fetch("/api/invitations"); } async getInvitation(invitationId: string): Promise { return this.fetch(`/api/invitations/${invitationId}`); } async acceptInvitation(invitationId: string): Promise { return this.fetch(`/api/invitations/${invitationId}/accept`, { method: "POST", }); } async declineInvitation(invitationId: string): Promise { await this.fetch(`/api/invitations/${invitationId}/decline`, { method: "POST", }); } async deleteWorkspace(workspaceId: string): Promise { await this.fetch(`/api/workspaces/${workspaceId}`, { method: "DELETE", }); } // Skills async listSkills(): Promise { return this.fetch("/api/skills"); } async getSkill(id: string): Promise { return this.fetch(`/api/skills/${id}`); } async createSkill(data: CreateSkillRequest): Promise { return this.fetch("/api/skills", { method: "POST", body: JSON.stringify(data), }); } async updateSkill(id: string, data: UpdateSkillRequest): Promise { return this.fetch(`/api/skills/${id}`, { method: "PUT", body: JSON.stringify(data), }); } async deleteSkill(id: string): Promise { await this.fetch(`/api/skills/${id}`, { method: "DELETE" }); } async importSkill(data: { url: string }): Promise { return this.fetch("/api/skills/import", { method: "POST", body: JSON.stringify(data), }); } async listAgentSkills(agentId: string): Promise { return this.fetch(`/api/agents/${agentId}/skills`); } async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise { await this.fetch(`/api/agents/${agentId}/skills`, { method: "PUT", body: JSON.stringify(data), }); } // Incremental attach: POST /skills/add only inserts the given ids (the // server upserts with ON CONFLICT DO NOTHING), so callers don't need to // read the agent's current skill set first. async addAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise { await this.fetch(`/api/agents/${agentId}/skills/add`, { method: "POST", body: JSON.stringify(data), }); } // Personal Access Tokens async listPersonalAccessTokens(): Promise { return this.fetch("/api/tokens"); } async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise { return this.fetch("/api/tokens", { method: "POST", body: JSON.stringify(data), }); } async revokePersonalAccessToken(id: string): Promise { await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); } // File Upload & Attachments async uploadFile( file: File, opts?: { issueId?: string; commentId?: string; chatSessionId?: string }, ): Promise { 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 { const query = params?.status ? `?status=${params.status}` : ""; return this.fetch(`/api/chat/sessions${query}`); } async getChatSession(id: string): Promise { return this.fetch(`/api/chat/sessions/${id}`); } async createChatSession(data: { agent_id: string; title?: string }): Promise { return this.fetch("/api/chat/sessions", { method: "POST", body: JSON.stringify(data), }); } async deleteChatSession(id: string): Promise { await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" }); } async updateChatSession(id: string, data: { title: string }): Promise { return this.fetch(`/api/chat/sessions/${id}`, { method: "PATCH", body: JSON.stringify(data), }); } async listChatMessages(sessionId: string): Promise { return this.fetch(`/api/chat/sessions/${sessionId}/messages`); } async listChatMessagesPage( sessionId: string, params: { before?: { created_at: string; id: string } | null; limit?: number } = {}, ): Promise { const limit = params.limit ?? 50; const query = new URLSearchParams({ limit: String(limit) }); if (params.before) { query.set("before_created_at", params.before.created_at); query.set("before_id", params.before.id); } try { return await this.fetch( `/api/chat/sessions/${sessionId}/messages/page?${query.toString()}`, ); } catch (err) { // Deployment-order compatibility: a backend deployed before this endpoint // existed returns 404 for the unknown route. Fall back to the legacy // full-list endpoint so chat never white-screens regardless of whether // the server or the client deploys first. Only the initial (cursorless) // page falls back — the legacy endpoint returns every message at once, so // the fallback page reports has_more: false and there is no follow-up // request to translate. A 404 on a cursor request is an unexpected state // and propagates instead of duplicating the whole list. if (err instanceof ApiError && err.status === 404 && !params.before) { const messages = await this.listChatMessages(sessionId); return { messages, limit, has_more: false, next_cursor: null }; } throw err; } } async sendChatMessage( sessionId: string, content: string, attachmentIds?: string[], ): Promise { 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 { return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`); } async listPendingChatTasks(): Promise { return this.fetch(`/api/chat/pending-tasks`); } async markChatSessionRead(sessionId: string): Promise { await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" }); } async cancelTaskById(taskId: string): Promise { const raw = await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" }); return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, { endpoint: "POST /api/tasks/{taskId}/cancel", }); } async listAttachments(issueId: string): Promise { 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 { const raw = await this.fetch(`/api/attachments/${id}`); return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, { endpoint: "GET /api/attachments/{id}", }); } async deleteAttachment(id: string): Promise { 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 { const search = new URLSearchParams(); if (params?.status) search.set("status", params.status); return this.fetch(`/api/projects?${search}`); } async getProject(id: string): Promise { return this.fetch(`/api/projects/${id}`); } async createProject(data: CreateProjectRequest): Promise { return this.fetch("/api/projects", { method: "POST", body: JSON.stringify(data), }); } async updateProject(id: string, data: UpdateProjectRequest): Promise { return this.fetch(`/api/projects/${id}`, { method: "PUT", body: JSON.stringify(data), }); } async deleteProject(id: string): Promise { await this.fetch(`/api/projects/${id}`, { method: "DELETE" }); } // Project resources async listProjectResources( projectId: string, ): Promise { return this.fetch(`/api/projects/${projectId}/resources`); } async createProjectResource( projectId: string, data: CreateProjectResourceRequest, ): Promise { return this.fetch(`/api/projects/${projectId}/resources`, { method: "POST", body: JSON.stringify(data), }); } async updateProjectResource( projectId: string, resourceId: string, data: UpdateProjectResourceRequest, ): Promise { return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, { method: "PUT", body: JSON.stringify(data), }); } async deleteProjectResource( projectId: string, resourceId: string, ): Promise { await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, { method: "DELETE", }); } // Labels async listLabels(): Promise { return this.fetch(`/api/labels`); } async getLabel(id: string): Promise