mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 18:06:45 +08:00
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) Replace the floating chat FAB/window with a first-class Chat tab under Inbox, laid out as an IM-style two-pane surface (thread list + conversation). Highlights: - New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable session selection; web + desktop routing wired up. Removes the old chat-fab / chat-window / resize-handles / context-items paths. - IM thread list: agent avatar + last-message preview + IM timestamp, red unread *count* badge (read-cursor model), presence-gated typing vs waiting. Rename lives only in the conversation header ⋯ menu (not the list hover). - Per-session conversation header (rename / view agent / delete), agent-aware empty state (avatar + name + description + starter prompts), and a deterministic clean-title derivation from the first message. - Server: read-cursor unread model (migration 145) and per-user pinned agents (migration 146, dedicated chat_pinned_agent table + handler/queries). New-agent welcome chat auto-enqueues a real agent run (LLM intro, no static template). - Design: fade the global --border token; borderless list headers on Chat/Inbox, kept (faded) on the conversation header. Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt. Co-authored-by: multica-agent <[email protected]> * feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230) The "meet your new agent" chat used to insert a fake user message ("👋 Hi! Please introduce yourself …") and have the agent reply to it, so the thread looked like the creator prompting the agent. Drop the persisted user message. Flag the auto-created session is_agent_intro (migration 147) and drive the intro run server-side: the daemon builds a proactive self-introduction prompt for such sessions (buildChatPrompt) instead of a "reply to their message" prompt. The intro stays LLM-generated; the thread now opens with the agent's own message, as if it reached out first. - migration 147: chat_session.is_agent_intro - CreateChatSession carries the flag; sendAgentWelcomeChat no longer persists/publishes a user message - daemon: ChatIntro threaded from session flag → intro prompt Co-authored-by: multica-agent <[email protected]> * feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080) * feat(chat): Settings toggle for the floating chat window (MUL-4235) Re-introduce the floating chat overlay on top of Chat V2 as an optional, Settings-gated surface instead of deleting it outright. - Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted client preference, default ON) to show/hide the floating window. - FloatingChat wrapper owns the two gates: the preference, and the /chat route (hidden on the tab so the same activeSessionId isn't shown twice). - ChatFab + a compact ChatWindow that reuse the shared useChatController and conversation components, so activeSessionId stays in lockstep with the tab. - Restore use-chat-context-items so the overlay's @ surfaces the current issue/project (the 'current context' affordance) — the tab stays manual. - i18n (en/zh-Hans/ja/ko), store unit tests. typecheck: core/views/web/desktop green. tests: chat store 9, settings 82, chat 39 pass. Co-authored-by: multica-agent <[email protected]> * feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235) Address review: give Chat its own Settings tab instead of a section inside Preferences, and default the floating window OFF (opt-in). - New Settings → Chat tab (chat-tab.tsx) under My Account; moves the floating-window toggle out of the Preferences tab. - floatingChatEnabled now defaults OFF — only an explicit enable from the Chat tab mounts the FAB/overlay. - i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko); revert the Preferences chat section and its test mock; store tests updated for the opt-in default. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081) The three starter prompts (List my open tasks by priority / Summarize what I did today / Plan what to work on next) read as filler more than help, so remove them along with the now-unused returning_subtitle ("Try asking"). The empty state keeps its agent-aware header — avatar + "Chat with {name}" + optional description — and the composer stays the entry point. Locale keys dropped across en/zh-Hans/ja/ko (parity preserved). Based on the Chat V2 branch (parent MUL-4171, #5076), not main. Co-authored-by: Lambda <[email protected]> * feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082) Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can keep important chats at the top of the IM-style thread list, above the activity-sorted rest. Backend: - migration 148: chat_session.pinned_at (nullable) + partial index; the timestamp doubles as the pinned-group sort key and the boolean flag. - list queries order pinned-first, then by most-recent activity. - SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler; pinning never bumps updated_at, so an unpinned chat won't jump the list. - ChatSessionResponse.pinned + chat:session_updated carries the new state. Frontend: - ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned with optimistic re-sort; shared sortChatSessions comparator. - thread list: pin indicator on pinned rows + pin/unpin hover action; list sorted pinned-first so it stays ordered after cache patches. - realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings. Tests: SetChatSessionPinned handler test, sortChatSessions unit tests. * feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088) Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093) Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the selected bg-accent reads as an inset rounded card — same treatment the chat thread list already uses — instead of a full-bleed, sharp-cornered highlight. Content stays 16px-inset (p-1 + px-3 == old px-4). MUL-4253 Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092) Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(chat): stop welcome intro session repeating the same introduction (MUL-4259) The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user. Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt. Co-authored-by: multica-agent <[email protected]> * fix(chat): address review findings + unbreak CI (MUL-4171) - task:failed now refreshes the sessions list (invalidateSessionLists), so the thread-list preview / unread / sort stays correct after an agent failure — FailTask persists a failure chat_message but only broadcasts task:failed, mirroring the chat:done success path. - Self-heal stale chat deep links: once the sessions list has loaded and a ?session= id isn't in it (deleted / no access / never existed) with nothing in flight, clear the selection instead of rendering an editable empty chat that would POST into a nonexistent session. Freshly-created sessions are exempt (they carry optimistic messages + a pending task). - CI: add the new parameterless `chat` route to link-handler's WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set + expectedSegments) — keeps the two in sync, fixes the failing @multica/core test. - Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to EmptyState. Co-authored-by: multica-agent <[email protected]> * fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171) Re-review follow-up: the stale-session self-heal only lived in ChatPage, so the floating ChatWindow still entered from a persisted activeSessionId and would render an editable empty chat (then POST into a nonexistent session) when the selected session was deleted / lost access off the /chat route. - Move the self-heal into the shared useChatController so every surface (tab and floating window) drops a dangling activeSessionId once the sessions list has loaded and doesn't contain it. - Harden ensureSession: trust the current id only when it's in the loaded list or is a just-created session still awaiting the refetch; a dangling id falls through to create a fresh session instead of POSTing into a 404. - Exempt just-created sessions via an OPTIMISTIC-write signal (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages — a session deleted elsewhere with real cached history stays eligible for self-heal. Add a unit test for the discriminator. Co-authored-by: multica-agent <[email protected]> * test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171) The AppSidebar personal nav gained a `chat` item, so it calls `useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't been updated, so `p.chat` was undefined and every render threw `TypeError: p[item.key] is not a function`, failing @multica/views#test in CI. - Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths. - Route the chat-sessions query key through a mutable `chatSessions` fixture. - Add coverage for the Chat nav: renders the link, badges the summed unread_count, and hides the badge when all sessions are read — so this drift is caught next time. Co-authored-by: multica-agent <[email protected]> * fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102) Follow-up to the merged #5080, which shipped a hand-written, simplified ChatWindow and lost the original's animations / drag-resize / expand-minimize. The floating window is just a quick entry point — it should be the original UI, not a rewrite. - Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and use-chat-resize.ts verbatim from main (0-diff): motion animations, drag resize, expand/minimize and the session dropdown are back. - Restore the empty_state.returning_subtitle + starter_prompts i18n keys the original window renders (V2 had dropped them); drop the now-unused window.open_full_tooltip key the rewrite added. - Settings gating is unchanged: FloatingChat still wraps the original FAB + window, gated by floatingChatEnabled (default off) and hidden on /chat. typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass. Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098) Restore an archive flow as the reversible sibling of delete: - Chat list hover now offers Archive (not Delete); pin/stop unchanged. - A footer entry ('Archived · N') opens an Archived view listing archived chats; hard delete lives only there (hover -> unarchive + delete, with the existing inline confirm). - Conversation header ⋯ menu mirrors this: active chats archive, archived chats unarchive/delete. Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived (SetChatSessionArchived), broadcasts status on chat:session_updated so other tabs re-sort into the right list. SendChatMessage already refuses archived sessions, so archived chats stay read-only until unarchived. Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100) * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) Co-authored-by: multica-agent <[email protected]> * refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265) Co-authored-by: multica-agent <[email protected]> * feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]> * fix(chat): address floating-window + archived-agent review blockers (MUL-4171) Re-review follow-up on the restored floating ChatWindow + archive flow: 1. Floating stale-session self-heal. The restored ChatWindow doesn't use the shared controller, so its ensureSession trusted any non-empty activeSessionId and there was no dangling-session cleanup — a deleted / no-access persisted session could send into a nonexistent session. Ported the same guard used for the tab: a self-heal effect that clears a dangling activeSessionId once the sessions list has loaded, and ensureSession only trusts an id that's in the list or has an in-flight optimistic write (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds the optimistic message + pending task before setActiveSession, so a freshly-created session is never mis-cleared. 2. Floating dropdown bypassed archive-first safety. Its active rows offered a hard-delete, letting the floating window destroy active chats and skip the "archive first, delete only from Archived" model. Active rows now ARCHIVE (reversible, one-click) like ChatThreadList; the floating window offers no hard-delete — unarchive/delete live only in the full Chat page's Archived view (reachable via expand). Removed the now-dead delete-confirm machinery. 3. Orphan user message on archived-agent send. SendChatMessage created the chat_message before EnqueueChatTask, which rejects an archived / runtime-less agent — a stale client would land a user message then get a 500, orphaning it. Added a preflight that checks the session agent's archived / runtime state and returns 409 before any mutation, plus a handler test asserting the send is rejected with no message persisted. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Lambda <[email protected]> Co-authored-by: multica-agent <[email protected]>
2417 lines
85 KiB
TypeScript
2417 lines
85 KiB
TypeScript
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,
|
||
RuntimeProfile,
|
||
CreateRuntimeProfileRequest,
|
||
UpdateRuntimeProfileRequest,
|
||
InboxItem,
|
||
InboxWorkspaceUnread,
|
||
IssueSubscriber,
|
||
Comment,
|
||
CommentTriggerPreview,
|
||
IssueTriggerPreview,
|
||
IssueTriggerPreviewParams,
|
||
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,
|
||
ChatPinnedAgent,
|
||
ChatMessage,
|
||
ChatMessagesPage,
|
||
ChatPendingTask,
|
||
PendingChatTasksResponse,
|
||
HasPendingChatTasksResponse,
|
||
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,
|
||
AutopilotCollaboratorsResponse,
|
||
ListAutopilotRunsResponse,
|
||
ListWebhookDeliveriesResponse,
|
||
WebhookDelivery,
|
||
NotificationPreferenceResponse,
|
||
NotificationPreferences,
|
||
GitHubPullRequest,
|
||
ListGitHubInstallationsResponse,
|
||
GitHubConnectResponse,
|
||
ListLarkInstallationsResponse,
|
||
BeginLarkInstallResponse,
|
||
LarkInstallStatusResponse,
|
||
RedeemLarkBindingTokenResponse,
|
||
ComposioToolkit,
|
||
ComposioConnection,
|
||
ComposioConnectInitResponse,
|
||
SlackInstallation,
|
||
ListSlackInstallationsResponse,
|
||
RegisterSlackBYORequest,
|
||
RedeemSlackBindingTokenResponse,
|
||
Squad,
|
||
SquadMember,
|
||
SquadMemberStatusListResponse,
|
||
BillingBalance,
|
||
BillingTransactionsPage,
|
||
BillingBatchesPage,
|
||
BillingTopupsPage,
|
||
BillingPriceTier,
|
||
CreateBillingCheckoutSessionRequest,
|
||
CreateBillingCheckoutSessionResponse,
|
||
BillingCheckoutSessionStatus,
|
||
CreateBillingPortalSessionResponse,
|
||
} from "../types";
|
||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||
import type { CreateFeedbackResponse, FeedbackKind } from "../feedback/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,
|
||
IssueTriggerPreviewSchema,
|
||
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_SEARCH_ISSUES_RESPONSE,
|
||
EMPTY_SEARCH_PROJECTS_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,
|
||
SearchIssuesResponseSchema,
|
||
SearchProjectsResponseSchema,
|
||
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,
|
||
CreateFeedbackResponseSchema,
|
||
EMPTY_CREATE_FEEDBACK_RESPONSE,
|
||
InboxUnreadSummarySchema,
|
||
EMPTY_INBOX_UNREAD_SUMMARY,
|
||
} 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<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> {
|
||
const raw = await this.fetch<unknown>("/api/me");
|
||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||
endpoint: "GET /api/me",
|
||
});
|
||
}
|
||
|
||
async markOnboardingComplete(payload?: {
|
||
completion_path?: OnboardingCompletionPath;
|
||
workspace_id?: string;
|
||
}): Promise<User> {
|
||
const raw = await this.fetch<unknown>("/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<User> {
|
||
const raw = await this.fetch<unknown>("/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<string, unknown>;
|
||
}): Promise<User> {
|
||
const raw = await this.fetch<unknown>("/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<User> {
|
||
const raw = await this.fetch<unknown>("/api/me", {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
return parseWithFallback(raw, UserSchema, EMPTY_USER, {
|
||
endpoint: "PATCH /api/me",
|
||
});
|
||
}
|
||
|
||
// 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?.assignee_types?.length) search.set("assignee_types", params.assignee_types.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<unknown>(path);
|
||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||
endpoint: "GET /api/issues",
|
||
});
|
||
}
|
||
|
||
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
|
||
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<unknown>(`/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<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");
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/issues/search?${search}`,
|
||
params.signal ? { signal: params.signal } : undefined,
|
||
);
|
||
return parseWithFallback(raw, SearchIssuesResponseSchema, EMPTY_SEARCH_ISSUES_RESPONSE, {
|
||
endpoint: "GET /api/issues/search",
|
||
});
|
||
}
|
||
|
||
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");
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/projects/search?${search}`,
|
||
params.signal ? { signal: params.signal } : undefined,
|
||
);
|
||
return parseWithFallback(raw, SearchProjectsResponseSchema, EMPTY_SEARCH_PROJECTS_RESPONSE, {
|
||
endpoint: "GET /api/projects/search",
|
||
});
|
||
}
|
||
|
||
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;
|
||
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;
|
||
kind?: FeedbackKind;
|
||
}): Promise<CreateFeedbackResponse> {
|
||
const raw = await this.fetch<unknown>("/api/feedback", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
return parseWithFallback(raw, CreateFeedbackResponseSchema, EMPTY_CREATE_FEEDBACK_RESPONSE, {
|
||
endpoint: "POST /api/feedback",
|
||
});
|
||
}
|
||
|
||
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",
|
||
});
|
||
}
|
||
|
||
/** 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<unknown>(
|
||
`/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<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[],
|
||
suppressAgentIds?: 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 } : {}),
|
||
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
|
||
}),
|
||
});
|
||
}
|
||
|
||
async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise<CommentTriggerPreview> {
|
||
const raw = await this.fetch<unknown>(`/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",
|
||
});
|
||
}
|
||
|
||
/** Dry-run the unified run-enqueue predicate for a prospective issue write
|
||
* (create / single assign / single status / batch). Returns the runs that
|
||
* would start; no side effect. The four entry points consult this instead
|
||
* of re-implementing the rule (MUL-3375). */
|
||
async previewIssueTrigger(params: IssueTriggerPreviewParams): Promise<IssueTriggerPreview> {
|
||
const raw = await this.fetch<unknown>("/api/issues/preview-trigger", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
...(params.issueIds?.length ? { issue_ids: params.issueIds } : {}),
|
||
...(params.isCreate ? { is_create: true } : {}),
|
||
...(params.assigneeType ? { assignee_type: params.assigneeType } : {}),
|
||
...(params.assigneeId ? { assignee_id: params.assigneeId } : {}),
|
||
...(params.status ? { status: params.status } : {}),
|
||
}),
|
||
});
|
||
return parseWithFallback(raw, IssueTriggerPreviewSchema, { triggers: [], total_count: 0 }, {
|
||
endpoint: "POST /api/issues/preview-trigger",
|
||
});
|
||
}
|
||
|
||
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, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<Comment> {
|
||
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<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" });
|
||
}
|
||
|
||
/**
|
||
* 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<AgentEnvResponse> {
|
||
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<AgentEnvResponse> {
|
||
return this.fetch(`/api/agents/${id}/env`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
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 listCloudRuntimeNodes(
|
||
params?: ListCloudRuntimeNodesParams,
|
||
): Promise<CloudRuntimeNode[]> {
|
||
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<unknown>(
|
||
`/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<CloudRuntimeNode> {
|
||
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<void> {
|
||
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<BillingBalance> {
|
||
const raw = await this.fetch<unknown>("/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<BillingTransactionsPage> {
|
||
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<unknown>(
|
||
`/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<BillingBatchesPage> {
|
||
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<unknown>(
|
||
`/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<BillingTopupsPage> {
|
||
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<unknown>(
|
||
`/api/cloud-billing/topups${query ? `?${query}` : ""}`,
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
BillingTopupsPageSchema,
|
||
EMPTY_BILLING_TOPUPS_PAGE,
|
||
{ endpoint: "GET /api/cloud-billing/topups" },
|
||
);
|
||
}
|
||
|
||
async listCloudBillingPriceTiers(): Promise<BillingPriceTier[]> {
|
||
const raw = await this.fetch<unknown>("/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<CreateBillingCheckoutSessionResponse> {
|
||
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<BillingCheckoutSessionStatus> {
|
||
// Stripe session ids are `cs_<base62>` 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<unknown>(
|
||
`/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<CreateBillingPortalSessionResponse> {
|
||
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<void> {
|
||
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";
|
||
/**
|
||
* Custom display name. Pass an empty string to clear it (the server
|
||
* reverts to the default name). Omit to leave it unchanged — a JSON
|
||
* `null` is treated as "unchanged", not "clear". See MUL-4217.
|
||
*/
|
||
custom_name?: string;
|
||
/** Apply custom_name to every runtime on the same machine. */
|
||
apply_to_machine?: boolean;
|
||
},
|
||
): Promise<AgentRuntime> {
|
||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
|
||
// passes the workspace id the same way the runtimes list resolves it.
|
||
// ---------------------------------------------------------------------
|
||
|
||
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
|
||
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
|
||
`/api/workspaces/${workspaceId}/runtime-profiles`,
|
||
);
|
||
return res.runtime_profiles ?? [];
|
||
}
|
||
|
||
async getRuntimeProfile(
|
||
workspaceId: string,
|
||
profileId: string,
|
||
): Promise<RuntimeProfile> {
|
||
return this.fetch(
|
||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||
);
|
||
}
|
||
|
||
async createRuntimeProfile(
|
||
workspaceId: string,
|
||
body: CreateRuntimeProfileRequest,
|
||
): Promise<RuntimeProfile> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async updateRuntimeProfile(
|
||
workspaceId: string,
|
||
profileId: string,
|
||
patch: UpdateRuntimeProfileRequest,
|
||
): Promise<RuntimeProfile> {
|
||
return this.fetch(
|
||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||
{
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
},
|
||
);
|
||
}
|
||
|
||
async deleteRuntimeProfile(
|
||
workspaceId: string,
|
||
profileId: string,
|
||
): Promise<void> {
|
||
await this.fetch(
|
||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||
{ method: "DELETE" },
|
||
);
|
||
}
|
||
|
||
async getRuntimeUsage(
|
||
runtimeId: string,
|
||
params?: { days?: number; tz?: string },
|
||
): Promise<RuntimeUsage[]> {
|
||
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<unknown>(
|
||
`/api/runtimes/${runtimeId}/usage?${search}`,
|
||
);
|
||
return parseWithFallback<RuntimeUsage[]>(raw, RuntimeUsageListSchema, [], {
|
||
endpoint: "GET /api/runtimes/:id/usage",
|
||
});
|
||
}
|
||
|
||
async getRuntimeTaskActivity(
|
||
runtimeId: string,
|
||
params?: { tz?: string },
|
||
): Promise<RuntimeHourlyActivity[]> {
|
||
// 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<unknown>(
|
||
`/api/runtimes/${runtimeId}/activity?${search}`,
|
||
);
|
||
return parseWithFallback<RuntimeHourlyActivity[]>(
|
||
raw,
|
||
RuntimeHourlyActivityListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/runtimes/:id/activity" },
|
||
);
|
||
}
|
||
|
||
async getRuntimeUsageByAgent(
|
||
runtimeId: string,
|
||
params?: { days?: number; tz?: string },
|
||
): Promise<RuntimeUsageByAgent[]> {
|
||
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<unknown>(
|
||
`/api/runtimes/${runtimeId}/usage/by-agent?${search}`,
|
||
);
|
||
return parseWithFallback<RuntimeUsageByAgent[]>(
|
||
raw,
|
||
RuntimeUsageByAgentListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/runtimes/:id/usage/by-agent" },
|
||
);
|
||
}
|
||
|
||
async getRuntimeUsageByHour(
|
||
runtimeId: string,
|
||
params?: { days?: number; tz?: string },
|
||
): Promise<RuntimeUsageByHour[]> {
|
||
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<unknown>(
|
||
`/api/runtimes/${runtimeId}/usage/by-hour?${search}`,
|
||
);
|
||
return parseWithFallback<RuntimeUsageByHour[]>(
|
||
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<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);
|
||
if (params.tz) search.set("tz", params.tz);
|
||
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; tz?: string },
|
||
): 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);
|
||
if (params.tz) search.set("tz", params.tz);
|
||
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; tz?: string },
|
||
): 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);
|
||
// `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<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||
raw,
|
||
DashboardAgentRunTimeListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/agent-runtime" },
|
||
);
|
||
}
|
||
|
||
async getDashboardRunTimeDaily(
|
||
params: { days?: number; project_id?: string | null; tz?: string },
|
||
): Promise<DashboardRunTimeDaily[]> {
|
||
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<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||
raw,
|
||
DashboardRunTimeDailyListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/runtime/daily" },
|
||
);
|
||
}
|
||
|
||
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, taskId?: string): Promise<AgentTask> {
|
||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||
method: "POST",
|
||
body: JSON.stringify(taskId ? { task_id: taskId } : {}),
|
||
});
|
||
}
|
||
|
||
// 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");
|
||
}
|
||
|
||
// Cross-workspace unread summary: one entry per workspace the user belongs
|
||
// to that has unread inbox items. Backs the workspace-switcher dot for
|
||
// OTHER workspaces. Schema-guarded so a contract drift hides the dot rather
|
||
// than crashing the sidebar.
|
||
async getInboxUnreadSummary(): Promise<InboxWorkspaceUnread[]> {
|
||
const raw = await this.fetch<unknown>("/api/inbox/unread-summary");
|
||
return parseWithFallback(raw, InboxUnreadSummarySchema, EMPTY_INBOX_UNREAD_SUMMARY, {
|
||
endpoint: "GET /api/inbox/unread-summary",
|
||
});
|
||
}
|
||
|
||
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<NotificationPreferenceResponse> {
|
||
return this.fetch(
|
||
"/api/notification-preferences",
|
||
workspaceSlug ? { headers: { "X-Workspace-Slug": workspaceSlug } } : undefined,
|
||
);
|
||
}
|
||
|
||
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
||
return this.fetch("/api/notification-preferences", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ preferences }),
|
||
});
|
||
}
|
||
|
||
// App Config
|
||
async getConfig(): Promise<AppConfigResponse> {
|
||
const raw = await this.fetch<unknown>("/api/config");
|
||
return parseWithFallback<AppConfigResponse>(raw, AppConfigSchema, EMPTY_APP_CONFIG, {
|
||
endpoint: "GET /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[]; issue_prefix?: string; avatar_url?: string }): 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),
|
||
});
|
||
}
|
||
|
||
// 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<void> {
|
||
await this.fetch(`/api/agents/${agentId}/skills/add`, {
|
||
method: "POST",
|
||
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 setChatSessionPinned(id: string, pinned: boolean): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}/pin`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ pinned }),
|
||
});
|
||
}
|
||
|
||
async setChatSessionArchived(id: string, archived: boolean): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}/archive`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ archived }),
|
||
});
|
||
}
|
||
|
||
// Quick-agent bar: per-user pinned agents.
|
||
async listChatPinnedAgents(): Promise<ChatPinnedAgent[]> {
|
||
return this.fetch("/api/chat/pinned-agents");
|
||
}
|
||
|
||
async pinChatAgent(agentId: string): Promise<ChatPinnedAgent> {
|
||
return this.fetch("/api/chat/pinned-agents", {
|
||
method: "POST",
|
||
body: JSON.stringify({ agent_id: agentId }),
|
||
});
|
||
}
|
||
|
||
async unpinChatAgent(agentId: string): Promise<void> {
|
||
await this.fetch(`/api/chat/pinned-agents/${agentId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||
}
|
||
|
||
async listChatMessagesPage(
|
||
sessionId: string,
|
||
params: { before?: { created_at: string; id: string } | null; limit?: number } = {},
|
||
): Promise<ChatMessagesPage> {
|
||
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<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 hasAnyPendingChatTasks(): Promise<HasPendingChatTasksResponse> {
|
||
return this.fetch(`/api/chat/pending-tasks/has-any`);
|
||
}
|
||
|
||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||
}
|
||
|
||
async cancelTaskById(taskId: string): Promise<CancelTaskResponse> {
|
||
const raw = await this.fetch<unknown>(`/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<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 updateProjectResource(
|
||
projectId: string,
|
||
resourceId: string,
|
||
data: UpdateProjectResourceRequest,
|
||
): Promise<ProjectResource> {
|
||
return this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||
method: "PUT",
|
||
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[]> {
|
||
const raw = await this.fetch<unknown>(`/api/squads`);
|
||
return parseWithFallback(raw, SquadListSchema, EMPTY_SQUAD_LIST, {
|
||
endpoint: "GET /api/squads",
|
||
}) as Squad[];
|
||
}
|
||
|
||
async getSquad(id: string): Promise<Squad> {
|
||
const raw = await this.fetch<unknown>(`/api/squads/${id}`);
|
||
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
|
||
endpoint: "GET /api/squads/:id",
|
||
}) as Squad;
|
||
}
|
||
|
||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||
const raw = await this.fetch<unknown>("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
|
||
endpoint: "POST /api/squads",
|
||
}) as Squad;
|
||
}
|
||
|
||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||
const raw = await this.fetch<unknown>(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||
return parseWithFallback(raw, SquadSchema, EMPTY_SQUAD, {
|
||
endpoint: "PUT /api/squads/:id",
|
||
}) as Squad;
|
||
}
|
||
|
||
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) });
|
||
}
|
||
|
||
// Per-squad members status snapshot: one row per member with derived
|
||
// working/idle/offline/unstable plus the issues each agent is currently
|
||
// running. Parsed with a lenient schema so a new server-side status
|
||
// value or extra field can't white-screen the Squad page (#2143).
|
||
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
|
||
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
|
||
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
|
||
endpoint: "GET /api/squads/:id/members/status",
|
||
}) as SquadMemberStatusListResponse;
|
||
}
|
||
|
||
// Autopilots
|
||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.status) search.set("status", params.status);
|
||
const raw = await this.fetch<unknown>(`/api/autopilots?${search}`);
|
||
return parseWithFallback(
|
||
raw,
|
||
ListAutopilotsResponseSchema,
|
||
EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse,
|
||
{ endpoint: "GET /api/autopilots" },
|
||
);
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
// Grant a workspace member explicit write access to the autopilot. Both
|
||
// grant and revoke return the full updated collaborator list so callers can
|
||
// refresh without a second round-trip.
|
||
async grantAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||
return this.fetch(`/api/autopilots/${id}/collaborators`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ user_id: userId }),
|
||
});
|
||
}
|
||
|
||
async revokeAutopilotAccess(id: string, userId: string): Promise<AutopilotCollaboratorsResponse> {
|
||
return this.fetch(`/api/autopilots/${id}/collaborators/${userId}`, {
|
||
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}`);
|
||
}
|
||
|
||
// Returns a single run including its full trigger_payload. List responses
|
||
// omit trigger_payload to keep them small (a webhook envelope can be
|
||
// up to 256 KiB × limit rows), so the detail view fetches via this route.
|
||
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
|
||
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
async rotateAutopilotTriggerWebhookToken(
|
||
autopilotId: string,
|
||
triggerId: string,
|
||
): Promise<AutopilotTrigger> {
|
||
return this.fetch(
|
||
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
|
||
{ method: "POST" },
|
||
);
|
||
}
|
||
|
||
// Webhook deliveries — list is slim (no raw_body / selected_headers /
|
||
// response_body); detail returns the full row. Both responses are parsed
|
||
// through a lenient schema so an unknown server-side `status` /
|
||
// `signature_status` value degrades to a generic row instead of dropping
|
||
// the whole list.
|
||
async listAutopilotDeliveries(
|
||
autopilotId: string,
|
||
params?: { limit?: number; offset?: number },
|
||
): Promise<ListWebhookDeliveriesResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.limit) search.set("limit", params.limit.toString());
|
||
if (params?.offset) search.set("offset", params.offset.toString());
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries?${search}`,
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
ListWebhookDeliveriesResponseSchema,
|
||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||
{ endpoint: "GET /api/autopilots/:id/deliveries" },
|
||
);
|
||
}
|
||
|
||
async getAutopilotDelivery(
|
||
autopilotId: string,
|
||
deliveryId: string,
|
||
): Promise<WebhookDelivery> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
WebhookDeliveryResponseSchema,
|
||
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
|
||
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
|
||
);
|
||
}
|
||
|
||
// Replay creates a NEW delivery row referencing the original via
|
||
// `replayed_from_delivery_id`. Server rejects replays of
|
||
// signature-invalid / rejected deliveries with 400 — the UI keeps the
|
||
// button disabled for those rows, but the server is the source of truth.
|
||
async replayAutopilotDelivery(
|
||
autopilotId: string,
|
||
deliveryId: string,
|
||
): Promise<WebhookDelivery> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
|
||
{ method: "POST" },
|
||
);
|
||
return parseWithFallback(
|
||
raw,
|
||
WebhookDeliveryResponseSchema,
|
||
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
|
||
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
|
||
);
|
||
}
|
||
|
||
// 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`);
|
||
}
|
||
|
||
// Lark integration
|
||
async listLarkInstallations(workspaceId: string): Promise<ListLarkInstallationsResponse> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/lark/installations`);
|
||
}
|
||
|
||
async beginLarkInstall(
|
||
workspaceId: string,
|
||
agentId: string,
|
||
region: "feishu" | "lark",
|
||
): Promise<BeginLarkInstallResponse> {
|
||
// The user picks the cloud explicitly in the UI ("Bind to Feishu"
|
||
// vs "Bind to Lark"), and the backend POSTs the device-flow `begin`
|
||
// against the corresponding accounts host (accounts.feishu.cn vs
|
||
// accounts.larksuite.com) so the QR renders against the right
|
||
// cloud up front. Empty / omitted region still resolves to Feishu
|
||
// server-side (RegionOrDefault) — we surface region as a required
|
||
// arg here so every call site is forced to make a deliberate
|
||
// choice rather than silently defaulting to mainland.
|
||
const search = new URLSearchParams({ agent_id: agentId, region });
|
||
return this.fetch(`/api/workspaces/${workspaceId}/lark/install/begin?${search.toString()}`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
async getLarkInstallStatus(workspaceId: string, sessionId: string): Promise<LarkInstallStatusResponse> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/lark/install/${sessionId}/status`);
|
||
}
|
||
|
||
async deleteLarkInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/lark/installations/${installationId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
async redeemLarkBindingToken(token: string): Promise<RedeemLarkBindingTokenResponse> {
|
||
return this.fetch(`/api/lark/binding/redeem`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ token }),
|
||
});
|
||
}
|
||
|
||
// Composio integration (MUL-3720). All routes are user-scoped (a connection
|
||
// belongs to a user, not a workspace), so none take a workspaceId.
|
||
|
||
/** The project's connectable Composio toolkits (those with an enabled auth
|
||
* config). Since MUL-4009 the backend filters out non-connectable toolkits,
|
||
* so every entry has `connectable: true`. A resolver/upstream failure is a
|
||
* 502 rather than an empty list. */
|
||
async listComposioToolkits(): Promise<ComposioToolkit[]> {
|
||
return this.fetch(`/api/integrations/composio/toolkits`);
|
||
}
|
||
|
||
/** The caller's active Composio connections. */
|
||
async listComposioConnections(): Promise<ComposioConnection[]> {
|
||
return this.fetch(`/api/integrations/composio/connections`);
|
||
}
|
||
|
||
/** Starts a hosted Composio connect flow for a toolkit and returns the
|
||
* redirect URL the browser should be sent to. */
|
||
async beginComposioConnect(toolkitSlug: string): Promise<ComposioConnectInitResponse> {
|
||
return this.fetch(`/api/integrations/composio/connect/init`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ toolkit_slug: toolkitSlug }),
|
||
});
|
||
}
|
||
|
||
/** Disconnects a Composio connection the caller owns. */
|
||
async deleteComposioConnection(connectionId: string): Promise<void> {
|
||
await this.fetch(`/api/integrations/composio/connections/${connectionId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Slack integration (MUL-3666)
|
||
async listSlackInstallations(workspaceId: string): Promise<ListSlackInstallationsResponse> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/slack/installations`);
|
||
}
|
||
|
||
// registerSlackBYO performs a bring-your-own-app install: the admin pastes the
|
||
// bot token (xoxb-) + app-level token (xapp-) of the Slack app they created,
|
||
// and the backend validates + persists it, returning the new installation.
|
||
async registerSlackBYO(
|
||
workspaceId: string,
|
||
agentId: string,
|
||
body: RegisterSlackBYORequest,
|
||
): Promise<SlackInstallation> {
|
||
const search = new URLSearchParams({ agent_id: agentId });
|
||
return this.fetch(`/api/workspaces/${workspaceId}/slack/install/byo?${search.toString()}`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async deleteSlackInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/slack/installations/${installationId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
async redeemSlackBindingToken(token: string): Promise<RedeemSlackBindingTokenResponse> {
|
||
return this.fetch(`/api/slack/binding/redeem`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ token }),
|
||
});
|
||
}
|
||
}
|