mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* feat(slack): single app-level Socket Mode connection routed by team_id (MUL-3666) Reshape the Slack adapter from the stage-3 per-installation Socket Mode model into the multi-tenant B2 connection model: ONE deployment-level Socket Mode connection (app-level xapp- token, env MULTICA_SLACK_APP_TOKEN) receives the Events API stream for every installed workspace and routes each inbound event to its channel_installation by team_id — the existing GetChannelInstallationByAppID routing, unchanged. - AppConnector: the single shared connection (slack/app_connector.go). No leader election — per the design "one (or a few)" connections are fine: each replica opens one, Slack delivers each event to one of them, and the existing (installation, message_id) two-phase dedup guarantees exactly-once processing. Resolves the per-team bot user id (via the same app_id query) to detect/strip @-mentions, since one connection serves many workspaces. - Inbound translation (Events API -> channel.InboundMessage) extracted to slack/inbound.go as free functions parameterized by the per-team bot identity. - channel.go trimmed to the outbound Send-only sender; per-installation config (config.go) no longer carries an app-level token — installs hold only the per-workspace bot token (xoxb-) for outbound, since xapp- can't be OAuth'd. - engine.Supervisor now skips channel types with no registered Factory, so Slack installs (driven by the app-level connector, not per-installation channels) no longer churn the lease/Build loop. - Wiring: router.go builds the connector when MULTICA_SLACK_APP_TOKEN is set; main.go runs it alongside the Supervisor. Feishu untouched; channel_* schema unchanged. Verified: go build ./..., go vet ./..., gofmt, and go test ./internal/integrations/... all pass. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): OAuth self-serve install backend (MUL-3666) Add the in-product OAuth install flow that creates Slack installations, the keystone the B2 connector consumes. - slack.InstallService: Begin (build authorize URL, seal workspace/agent/ initiator into the OAuth state), Complete (verify state, exchange code via oauth.v2.access, upsert channel_type='slack' install with the bot token encrypted at rest, auto-bind the installer's Slack id so their first message is not dropped), plus List/Get/Revoke. State is stateless: sealed with the deployment secretbox + an embedded expiry, no session store. - HTTP handlers (handler/slack.go): member-visible list, admin-only begin + revoke, and the public OAuth callback (recovers context from the sealed state, redirects the browser back to Settings → Integrations with a result flag). - Routes + wiring: workspace-scoped list/begin/revoke mirror the Lark admin/member split; the callback is a public route like GitHub's. Built from MULTICA_SLACK_CLIENT_ID/SECRET (+ redirect derived from MULTICA_PUBLIC_URL, override MULTICA_SLACK_REDIRECT_URL; scopes via MULTICA_SLACK_SCOPES). - Realtime: slack_installation:created / :revoked events. Verified: go build ./..., go vet, gofmt, and go test ./internal/integrations/slack/... all pass (new install_test.go covers state sign/verify/expiry/tamper, authorize URL, code exchange + encrypted upsert + installer bind, and oauth error paths). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): in-product OAuth install UI for web + desktop (MUL-3666) Add the "Connect Slack" self-serve install UI mirroring the Feishu/Lark integration, completing the in-product install half of B2. Slack's OAuth flow is a redirect (not a device-code QR poll), so the UI is simpler than Lark's. - core: SlackInstallation / List / Begin types; api.listSlackInstallations / beginSlackInstall / deleteSlackInstallation; slackKeys + slackInstallationsOptions query; realtime invalidation on slack_installation:* events. - views: slack-tab.tsx (SlackTab settings panel + per-agent SlackAgentBindButton + connected badge + disconnect confirm). Connect calls beginSlackInstall and hands the authorize URL to openExternal (system browser on desktop, new tab on web); Slack bounces to the backend callback which lands the install, and the realtime event refreshes the list. Wired into the Settings → Integrations tab and the agent-detail Integrations tab alongside Lark. - i18n: en + zh-Hans settings.slack.* strings. Verified: pnpm typecheck (full monorepo, 6/6) and pnpm lint (@multica/core, @multica/views — 0 errors) pass. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): outbound Replier + user-binding redeem flow (MUL-3666) Fill the stage-3 Replier=nil tail so non-installer Slack users can onboard and get status feedback — completing B2 end to end. - slack.OutboundReplier (engine.OutboundReplier): on NeedsBinding it mints a single-use binding token and DMs/replies a "link your account" prompt with the redeem URL (wrapped as <url|label> so formatMrkdwn doesn't mangle the base64url token); on AgentOffline/AgentArchived it posts a status notice; on an /issue-created Ingest it confirms the new issue. Plain chat stays silent (the agent's own reply lands via EventChatDone). Reuses the bot-token Send path and reads the installation row from ResolvedInstallation.Platform — no new transport. - slack.BindingTokenService: Mint + transactional RedeemAndBind over the generic channel_binding_token / channel_user_binding queries (channel_type='slack'), mirroring lark.BindingTokenService. 15-min TTL, SHA256-hashed tokens, the three typed failure modes (invalid/expired, already-assigned, not-member). - HTTP: POST /api/slack/binding/redeem (public, session-authed) maps the failures to 410/409/403. NewSlackResolverSet now takes the replier (nil disables it). - Frontend: /slack/bind redeem page (packages/views/slack + apps/web route) + api.redeemSlackBindingToken + en/zh slack_bind copy. Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/... (new replier_test.go covers all outcome branches + the prompt URL), plus full pnpm typecheck (6/6) and pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): address review must-fixes — connector leak, team-keyed install, /issue copy (MUL-3666) Three fixes from Niko's review: 1. AppConnector.connectOnce leaked the Socket Mode goroutine/connection on a handler error: it ran sm.RunContext on the long-lived ctx and returned the error without cancelling it, so a transient DB/router error left the old connection alive (consuming events into an unread channel) while Run opened a second one. Each connection now runs under its own cancellable context and a deferred cancel + join tears it down on every exit path before reconnect. 2. Slack re-install collided with the (channel_type, app_id) unique index: connecting the same Slack team to a different agent failed because the upsert conflict key was (workspace_id, agent_id, channel_type). Add a team-keyed UpsertChannelInstallationByAppID (ON CONFLICT on the (channel_type, app_id) index, updating agent_id) and use it for the Slack OAuth install, so re-connecting a workspace moves the bot to the chosen agent instead of erroring. Feishu's per-agent upsert is unchanged. 3. /issue clarified: it is not a registered Slack slash command (no `commands` scope), so Slack never routes one to us. Issue creation runs through the message path — `@bot /issue <title>` in a channel or `/issue <title>` in a DM — which the engine parser handles. Documented in the connector and the user-facing copy (en + zh). Verified: go build ./..., go vet, gofmt, go test ./internal/integrations/..., make sqlc, plus pnpm typecheck (6/6) and pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): make OAuth install transactional — agent-move binding consistency + cross-workspace guard (MUL-3666) Address Elon's review: the team-keyed upsert kept the same installation row and only flipped agent_id, but engine session reuse matches purely on (installation_id, channel_chat_id) and each chat_session is permanently tied to the agent it was created under — so after moving a Slack team from Agent A to Agent B, existing DMs/threads kept routing to Agent A; only brand-new channels/threads reached B. Cross-workspace re-install was worse: the SQL also moved workspace_id while the application-layer user/chat-session bindings stayed behind, inheriting the previous workspace's relations. InstallService.Complete now runs one transaction (lookup → upsert → retire → installer-bind), all application-layer per the no-FK rule: - Look up the existing installation by team_id (config->>'app_id'). - Reject a silent cross-workspace ownership change (ErrTeamOwnedByAnotherWorkspace → callback redirects with slack_error=team_in_other_workspace). The owning workspace must disconnect first. - On an agent change within the same workspace, retire the installation's chat-session bindings (new DeleteChannelChatSessionBindingsByInstallation) so the next message creates a fresh session under the new agent. The chat_session rows are preserved for history; user bindings stay valid (same users/workspace). - Installer auto-bind moves into the tx; an already-bound-elsewhere id is a benign skip, a real DB error aborts the whole install. InstallService now takes a TxStarter; the queries seam gains WithTx (dbInstallQueries adapter) so Complete stays unit-testable with a fake tx. Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/... (new tests: agent-move retire, same-agent no-retire, cross-workspace reject, fresh-install no-retire). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): atomic cross-workspace install guard + green up frontend CI (MUL-3666) Two things: address Elon's review and fix the failing frontend CI job. Review (atomic cross-workspace guard): the previous guard was a SELECT before the upsert, which loses the concurrent-OAuth race — two workspaces can both read no rows, one inserts, the other's ON CONFLICT update then silently re-points the team. Move the guard into the upsert itself: ON CONFLICT ... DO UPDATE ... WHERE channel_installation.workspace_id = EXCLUDED.workspace_id, and map the empty RETURNING (pgx.ErrNoRows) to ErrTeamOwnedByAnotherWorkspace. The pre-SELECT now only feeds the agent-change cleanup. Also corrected the error copy: a team stays bound to its first Multica workspace (revoke is soft, keeping the row + unique index), so migration is an operator action, not "disconnect first". CI (frontend vitest, @multica/views#test): - The agent IntegrationsTab now renders the real SlackAgentBindButton, whose connected badge calls useQueryClient — absent from integrations-tab.test.tsx's react-query mock. Hoisted the owner/admin gate above the per-platform sections (one role notice instead of one per platform), made the agents members_note generic (en/zh/ja/ko), and updated the test (mock @multica/core/slack, stub SlackAgentBindButton, assert both platforms). - Added slack-tab.test.tsx covering the real SlackAgentBindButton / SlackTab. - locale parity: added the slack (settings) + slack_bind (common) blocks to ja and ko so every EN key has a translated counterpart. Verified: make sqlc, go build ./..., go vet, gofmt, go test ./internal/integrations/...; pnpm --filter @multica/views test (1478 pass), pnpm typecheck (6/6), pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): surface agent-page Slack entry points when Lark is off (MUL-3666) The agent-detail Integrations tab and the inspector's Integrations section only considered Lark, so a Slack-only deployment (Lark disabled) showed neither the Integrations tab nor a Connect-Slack button — the per-agent entry points were unreachable. - agent-overview-pane: gate the Integrations tab on Lark OR Slack configured (new slackInstallationsOptions query), not Lark alone. - agent-detail-inspector: render SlackAgentBindButton alongside LarkAgentBindButton in the Integrations section. - regression test: the Integrations tab appears when only Slack is configured. Verified: pnpm typecheck (6/6), pnpm --filter @multica/views test (1478+ pass), pnpm lint (0 errors). Co-authored-by: multica-agent <github@multica.ai> * feat(slack): BYO-app install backend — paste xoxb+xapp, per-app install keyed by real app id (MUL-3666) Adds the bring-your-own-app install path so multiple agents can each have their own bot identity in the SAME Slack workspace (hosted B2 caps at one agent/workspace). User pastes their app's bot token (xoxb-) + app-level token (xapp-); we validate the bot token via auth.test, parse the real Slack app id from the xapp- token, encrypt both tokens, and persist a per-app installation keyed by that app id (real 'A…' ids never collide with hosted 'T…' team ids in the existing unique index — no schema change). - config.go: add app_token_encrypted (BYO discriminator + per-app socket token) - install.go: extract shared persistInstall (atomic cross-ws guard + agent-move retire) - byo_install.go: RegisterBYO + auth.test + app-id parse - handler + route: POST /api/workspaces/{id}/slack/install/byo (admin-only) - tests: keying, encryption, invalid tokens, auth.test failure, cross-ws, agent move Follow-ups (separate commits): per-app Socket Mode connector that consumes the stored app token; in-product BYO install dialog (video + paste form). Co-authored-by: multica-agent <github@multica.ai> * refactor(slack): drop OAuth, unify on BYO per-installation model (MUL-3666) Per product decision, Slack drops the hosted-app OAuth path entirely and unifies on bring-your-own-app (BYO): every installation carries its OWN app-level token and gets its OWN Socket Mode connection, so multiple agents can each have a distinct bot identity in one Slack workspace. - Remove OAuth install (Begin/Complete/code-exchange/sealed state/OAuthConfig/ default scopes), the OAuth callback + begin handlers + routes, and the MULTICA_SLACK_CLIENT_ID/SECRET/REDIRECT/APP_TOKEN env wiring. - Replace the single deployment-level AppConnector with a per-installation slackChannel (authenticated with its own xapp- token) registered as a channel Factory, so the engine Supervisor drives one Socket Mode connection per installation (exactly like Feishu). inbound/outbound/resolvers reused as-is. - Route inbound by the event's api_app_id (== the installation's real app id), not team_id. - InstallService slims to at-rest encryption + the shared persistInstall + list/get/revoke; install is the BYO paste path only (byo_install.go). - Tests: drop the OAuth tests; slack + handler + engine all green. Follow-up (frontend): replace the OAuth "Connect Slack" button with the BYO paste dialog (the begin endpoint it calls is now gone). Co-authored-by: multica-agent <github@multica.ai> * fix(slack): verify BYO bot + app tokens are from the same app, and the app token is live (MUL-3666) Niko review: RegisterBYO only parsed the app id from the xapp string and auth.test'd the bot token, so pasting app A's bot token with app B's app token would 'connect' but be broken (inbound on B's socket, outbound with A's identity). Now: resolve the bot's owning app id via bots.info (on the bot_id from auth.test) and require it to equal the xapp's app id; and live- validate the app token via apps.connections.open. Reject (no persist) on mismatch or a dead app token. Co-authored-by: multica-agent <github@multica.ai> * feat(slack): in-product BYO install dialog (paste bot + app tokens) (MUL-3666) The OAuth begin endpoint was removed server-side, so the "Connect Slack" button now opens a dialog where the admin pastes the bot token (xoxb-) and app-level token (xapp-) of the Slack app they created, and submits to the BYO install endpoint. Includes an optional setup-video link (URL constant, left empty until the walkthrough is recorded). - core: drop beginSlackInstall / BeginSlackInstallResponse; add registerSlackBYO + RegisterSlackBYORequest. - views: SlackAgentBindButton opens the BYO dialog; refreshed comments and install_supported docs (now means "configured", no OAuth). - i18n: new slack.byo_* keys + refreshed page_description in en/zh-Hans/ja/ko. - tests: dialog submit path; views vitest (1479), typecheck, lint, locale parity all green. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): Elon review — team_id routing guard, per-agent reconnect, users:read hint (MUL-3666) 1. Inbound routing keys on api_app_id (the APP, not the Slack workspace), so additionally require the event's team_id to match the installation's stored team. A distributed BYO app installed into another Slack workspace emits the same app id and would otherwise mis-route to this Multica installation. Extracted installationServesTeam() + unit test. 2. BYO install is now agent-keyed (UpsertChannelInstallation, conflict on workspace_id+agent_id+channel_type): one bot per agent. Disconnect → reconnect a NEW app for the SAME agent now UPDATES that agent's row in place instead of violating the (workspace, agent, channel) unique. A unique violation on the (channel_type, app_id) routing index → ErrTeamOwnedByAnother- Workspace (the app is already connected to another agent/workspace). No chat-session retire is needed: a row's agent_id never changes. 3. UX: bots.info (the same-app check) needs the users:read scope — the connect dialog now lists the required bot scopes including it, and the error text says so. Backend build/vet/gofmt/test + views vitest + typecheck + locale parity green. Co-authored-by: multica-agent <github@multica.ai> * fix(slack): publish slack_installation:created on BYO connect; refresh stale comments (MUL-3666) Niko final review: RegisterSlackBYO wrote the response but never published EventSlackInstallationCreated, so only the installer's own tab refreshed — other open clients (Settings, Agent Integrations, other tabs) did not see the new bot in realtime, inconsistent with the revoke event and Lark. Now publishes it on success via a small publishSlackInstallationCreated helper, with a unit test (Bus.Publish is synchronous). Also refreshed comments that still described the removed hosted-OAuth / single deployment-level AppConnector model (handler SlackInstall field, channel.go / inbound.go / outbound.go / byo_install.go). PR title updated separately to the BYO per-installation Socket Mode model. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
2311 lines
81 KiB
TypeScript
2311 lines
81 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,
|
||
ChatMessage,
|
||
ChatMessagesPage,
|
||
ChatPendingTask,
|
||
PendingChatTasksResponse,
|
||
SendChatMessageResponse,
|
||
CancelTaskResponse,
|
||
Project,
|
||
CreateProjectRequest,
|
||
UpdateProjectRequest,
|
||
ListProjectsResponse,
|
||
ProjectResource,
|
||
CreateProjectResourceRequest,
|
||
UpdateProjectResourceRequest,
|
||
ListProjectResourcesResponse,
|
||
Label,
|
||
CreateLabelRequest,
|
||
UpdateLabelRequest,
|
||
ListLabelsResponse,
|
||
IssueLabelsResponse,
|
||
PinnedItem,
|
||
CreatePinRequest,
|
||
PinnedItemType,
|
||
ReorderPinsRequest,
|
||
Invitation,
|
||
Autopilot,
|
||
AutopilotTrigger,
|
||
AutopilotRun,
|
||
CreateAutopilotRequest,
|
||
UpdateAutopilotRequest,
|
||
CreateAutopilotTriggerRequest,
|
||
UpdateAutopilotTriggerRequest,
|
||
ListAutopilotsResponse,
|
||
GetAutopilotResponse,
|
||
ListAutopilotRunsResponse,
|
||
ListWebhookDeliveriesResponse,
|
||
WebhookDelivery,
|
||
NotificationPreferenceResponse,
|
||
NotificationPreferences,
|
||
GitHubPullRequest,
|
||
ListGitHubInstallationsResponse,
|
||
GitHubConnectResponse,
|
||
ListLarkInstallationsResponse,
|
||
BeginLarkInstallResponse,
|
||
LarkInstallStatusResponse,
|
||
RedeemLarkBindingTokenResponse,
|
||
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 {
|
||
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,
|
||
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?.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;
|
||
}): Promise<{ id: string; created_at: string }> {
|
||
return this.fetch("/api/feedback", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
||
return this.fetch(`/api/issues/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
||
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
||
endpoint: "GET /api/issues/:id/children",
|
||
});
|
||
}
|
||
|
||
/** 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" },
|
||
): 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 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 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" });
|
||
}
|
||
|
||
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 }),
|
||
});
|
||
}
|
||
|
||
// 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 }),
|
||
});
|
||
}
|
||
}
|