mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal
Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:
- Who I am (built-in workspace assistant, not onboarding-only)
- What Multica is + docs/source/issues URLs as knowledge sources
- What I can do (CLI = manifest, `multica --help` is the source of truth)
- Tone (concise, like a colleague, match user's language)
Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.
Side effects required to make `onboarded_at == null` an honest signal:
- CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
The "member exists ⟹ onboarded_at != null" invariant is intentionally
broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
this — comments updated to reflect the new contract.
- AcceptInvitation still marks (invitee skips the modal in someone
else's workspace). Code comment added warning future removers.
- resolvePostAuthDestination flips to workspace-presence-first: a user
with a workspace lands in it regardless of `onboarded_at`, so the
modal can pick up an interrupted setup on relogin.
Other backend changes:
- `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
- `onboardingAssistantInstructions` rewritten to the 3-section identity
- `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
cap, empty-falls-back-to onboardingIssueDescription)
Frontend changes:
- Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
persisted step)
- `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
- `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
bootstrap, `onboarded_at` stays NULL so the modal fires
- Runtime step next-button copy → "Start exploring" / "开始探索"
- New `packages/views/workspace/onboarding-helper-modal.tsx`:
Base UI Dialog, dismissible=false, three localized cards, mutation
invalidates agents + issues queries then navigates to the seeded issue
- Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
`apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`
Tests:
- Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
TestCreateWorkspace_DoesNotMarkOnboarded
- Frontend: onboarding-helper-modal.test.tsx covers all four gating
conditions, three-card behavior, mutation pending state, and the
"no close button" invariant
Compatibility:
- Already-onboarded users: zero impact (modal can't fire)
- Invitees: AcceptInvitation still marks → modal can't fire
- Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
- Old desktop / web clients: legacy teammate-step path keeps working
(bootstrap accepts missing starter_prompt) — the new modal only fires
on the new frontend bundle
- Avatar SVG kept (asterisk variant) — no migration of existing Helper
agents, only newly-created Helpers pick up the new instructions/description
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open
On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).
Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.
Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(onboarding): add v2 refactor plan
Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)
Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.
PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.
Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(server): collapse onboarding side effects to service layer
Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:
- MarkUserOnboarded + claim starter_content_state +
optional install-runtime fallback seed: was inline in
BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
AcceptInvitation, and CompleteOnboarding.
- install-runtime issue seeding: was inline in CreateWorkspace and
AcceptInvitation as a "no runtime yet" fallback.
After this refactor:
- MarkUserOnboarded is called from exactly one place (the service).
- install-runtime issue is seeded from exactly one place (the service).
- CreateWorkspace deliberately does not seed — the new
/ensure-onboarding-content endpoint (also added here) lets the
workspace-entry init component request the seed on first mount, so
workspaces created but never opened don't accumulate stale issues.
- The PatchOnboarding handler now accepts the new runtime_id /
runtime_skipped fields and rejects (uuid, skipped=true) up front.
- UserResponse exposes the two new persisted fields so the frontend
can read them off `me` without an extra round-trip.
Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.
Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): API + types for persisted onboarding runtime choice
User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.
Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal
Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:
branch 0 me.onboarded_at != null → ensure install-runtime issue
fallback, render nothing
branch 1 me.onboarding_runtime_skipped → SkipBootstrapping component:
loading veil → bootstrap →
navigate. On failure shows
a Retry UI instead of
silently freezing the veil
branch 2 me.onboarding_runtime_id → render Modal with the
runtime id from `me` (no
internal list query)
branch 3 (none of the above) → useEffect navigate back to
/onboarding so the user
walks Step 3 again
The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.
Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): Step 3 records user choice instead of calling bootstrap
handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.
PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.
The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): v3 — thin server, frontend-orchestrated welcome
Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.
V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
- runtime path: find-or-create Multica Helper via generic createAgent
with bilingual instructions from `templates/helper-instructions.ts`,
blocking modal with 3 starter cards, pick -> createIssue + navigate
- skip path: provision install-runtime (in_progress) -> agent-guide
(todo, body embeds install-runtime mention chip) -> follow-up comment
on install-runtime mentioning agent-guide; then pop celebration
modal with 🎉 emoji pop animation, 2 read-only preview cards, single
[Got it] CTA that navigates to install-runtime
Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
deprecation shim in onboarding_shim.go for desktop < v3 during the
rollout window — handlers inlined to qtx.* calls, no service layer
Localization
- Persisted strings (issue titles/bodies, Helper instructions/
description, comment prefix) live as TS const `{en, zh}` maps in
`packages/views/onboarding/templates/` — i18n bundle staleness can no
longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
`packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
null for new users until they pick a preference)
Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
`postCommentDeduped`) so React StrictMode double-mount can't fire two
parallel API calls that the server would then 409
Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety
Welcome modal polish (the post-Step-3 surface this branch already
introduced):
Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
fade. New copy: "Hi, welcome to Multica / I'm your first Agent
assistant" + capability hint sentence so users discover assignment +
chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
border-primary + ring selection pattern used by compact-runtime-row;
bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
Helper to paste an HTML welcome page into the issue comment — works
on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
"All set!" + "Sit tight ☕ — your {agentName} is on it" + inbox/chat
hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
bundle being loaded); subtitle stays in onboarding.json.
Skip path
- Body restructured into three independent ```md blocks (Name /
Description / Instructions) so each picks up the markdown renderer's
per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
[identifier](mention://issue/uuid) so it renders as the styled
IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.
Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
useWelcomeStore.reset() so user B logging into the same browser
doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
currentWorkspace.id === signal.workspaceId — prevents firing the
modal in workspace B when the signal was parked for workspace A
(desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
pendingIssueSeed, pendingCommentSeed) for the three API calls so
React 18+ StrictMode dev double-mount can't race-create duplicates.
Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
read i18n.language (not me.language, which is null for new users
who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
avatar (kept the bouncy variant for the skip 🎉 hero where the
celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
one only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(views): i18n hardcoded "Close" in welcome FullScreenError
CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1775 lines
60 KiB
TypeScript
1775 lines
60 KiB
TypeScript
import type {
|
||
Issue,
|
||
CreateIssueRequest,
|
||
UpdateIssueRequest,
|
||
GroupedIssuesResponse,
|
||
ListIssuesResponse,
|
||
SearchIssuesResponse,
|
||
SearchProjectsResponse,
|
||
UpdateMeRequest,
|
||
CreateMemberRequest,
|
||
UpdateMemberRequest,
|
||
ListIssuesParams,
|
||
ListGroupedIssuesParams,
|
||
Agent,
|
||
CreateAgentRequest,
|
||
AgentTemplate,
|
||
AgentTemplateSummary,
|
||
CreateAgentFromTemplateRequest,
|
||
CreateAgentFromTemplateResponse,
|
||
UpdateAgentRequest,
|
||
AgentTask,
|
||
AgentActivityBucket,
|
||
AgentRunCount,
|
||
AgentRuntime,
|
||
InboxItem,
|
||
IssueSubscriber,
|
||
Comment,
|
||
Reaction,
|
||
IssueReaction,
|
||
Workspace,
|
||
WorkspaceRepo,
|
||
MemberWithUser,
|
||
User,
|
||
Skill,
|
||
SkillSummary,
|
||
CreateSkillRequest,
|
||
UpdateSkillRequest,
|
||
SetAgentSkillsRequest,
|
||
PersonalAccessToken,
|
||
CreatePersonalAccessTokenRequest,
|
||
CreatePersonalAccessTokenResponse,
|
||
RuntimeUsage,
|
||
IssueUsageSummary,
|
||
RuntimeHourlyActivity,
|
||
RuntimeUsageByAgent,
|
||
RuntimeUsageByHour,
|
||
DashboardUsageDaily,
|
||
DashboardUsageByAgent,
|
||
DashboardAgentRunTime,
|
||
DashboardRunTimeDaily,
|
||
RuntimeUpdate,
|
||
RuntimeModelListRequest,
|
||
RuntimeLocalSkillListRequest,
|
||
CreateRuntimeLocalSkillImportRequest,
|
||
RuntimeLocalSkillImportRequest,
|
||
TimelineEntry,
|
||
AssigneeFrequencyEntry,
|
||
TaskMessagePayload,
|
||
Attachment,
|
||
ChatSession,
|
||
ChatMessage,
|
||
ChatPendingTask,
|
||
PendingChatTasksResponse,
|
||
SendChatMessageResponse,
|
||
Project,
|
||
CreateProjectRequest,
|
||
UpdateProjectRequest,
|
||
ListProjectsResponse,
|
||
ProjectResource,
|
||
CreateProjectResourceRequest,
|
||
ListProjectResourcesResponse,
|
||
Label,
|
||
CreateLabelRequest,
|
||
UpdateLabelRequest,
|
||
ListLabelsResponse,
|
||
IssueLabelsResponse,
|
||
PinnedItem,
|
||
CreatePinRequest,
|
||
PinnedItemType,
|
||
ReorderPinsRequest,
|
||
Invitation,
|
||
Autopilot,
|
||
AutopilotTrigger,
|
||
AutopilotRun,
|
||
CreateAutopilotRequest,
|
||
UpdateAutopilotRequest,
|
||
CreateAutopilotTriggerRequest,
|
||
UpdateAutopilotTriggerRequest,
|
||
ListAutopilotsResponse,
|
||
GetAutopilotResponse,
|
||
ListAutopilotRunsResponse,
|
||
ListWebhookDeliveriesResponse,
|
||
WebhookDelivery,
|
||
NotificationPreferenceResponse,
|
||
NotificationPreferences,
|
||
GitHubPullRequest,
|
||
ListGitHubInstallationsResponse,
|
||
GitHubConnectResponse,
|
||
Squad,
|
||
SquadMember,
|
||
SquadMemberStatusListResponse,
|
||
} 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,
|
||
ChildIssuesResponseSchema,
|
||
CommentsListSchema,
|
||
CloudRuntimeNodeListSchema,
|
||
CloudRuntimeNodeSchema,
|
||
CreateAgentFromTemplateResponseSchema,
|
||
DashboardAgentRunTimeListSchema,
|
||
DashboardRunTimeDailyListSchema,
|
||
DashboardUsageByAgentListSchema,
|
||
DashboardUsageDailyListSchema,
|
||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||
EMPTY_ATTACHMENT,
|
||
EMPTY_CLOUD_RUNTIME_NODE,
|
||
EMPTY_CLOUD_RUNTIME_NODE_LIST,
|
||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||
EMPTY_LIST_ISSUES_RESPONSE,
|
||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||
EMPTY_TIMELINE_ENTRIES,
|
||
EMPTY_USER,
|
||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||
EMPTY_WEBHOOK_DELIVERY,
|
||
GroupedIssuesResponseSchema,
|
||
ListIssuesResponseSchema,
|
||
ListWebhookDeliveriesResponseSchema,
|
||
RuntimeHourlyActivityListSchema,
|
||
RuntimeUsageByAgentListSchema,
|
||
RuntimeUsageByHourListSchema,
|
||
RuntimeUsageListSchema,
|
||
SquadMemberStatusListResponseSchema,
|
||
SubscribersListSchema,
|
||
TimelineEntriesSchema,
|
||
UserSchema,
|
||
WebhookDeliveryResponseSchema,
|
||
} 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");
|
||
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);
|
||
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");
|
||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||
}
|
||
|
||
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
||
const search = new URLSearchParams({ q: params.q });
|
||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||
if (params.include_closed) search.set("include_closed", "true");
|
||
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||
}
|
||
|
||
async getIssue(id: string): Promise<Issue> {
|
||
return this.fetch(`/api/issues/${id}`);
|
||
}
|
||
|
||
async createIssue(data: CreateIssueRequest): Promise<Issue> {
|
||
return this.fetch("/api/issues", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async quickCreateIssue(data: {
|
||
agent_id?: string;
|
||
squad_id?: string;
|
||
prompt: string;
|
||
project_id?: string | null;
|
||
}): Promise<{ task_id: string }> {
|
||
return this.fetch("/api/issues/quick-create", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async createFeedback(data: {
|
||
message: string;
|
||
url?: string;
|
||
workspace_id?: string;
|
||
}): Promise<{ id: string; created_at: string }> {
|
||
return this.fetch("/api/feedback", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
||
return this.fetch(`/api/issues/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
||
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
||
endpoint: "GET /api/issues/:id/children",
|
||
});
|
||
}
|
||
|
||
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
||
return this.fetch("/api/issues/child-progress");
|
||
}
|
||
|
||
async deleteIssue(id: string): Promise<void> {
|
||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> {
|
||
return this.fetch("/api/issues/batch-update", {
|
||
method: "POST",
|
||
body: JSON.stringify({ issue_ids: issueIds, updates }),
|
||
});
|
||
}
|
||
|
||
async batchDeleteIssues(issueIds: string[]): Promise<{ deleted: number }> {
|
||
return this.fetch("/api/issues/batch-delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ issue_ids: issueIds }),
|
||
});
|
||
}
|
||
|
||
// Comments
|
||
async listComments(issueId: string): Promise<Comment[]> {
|
||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
|
||
return parseWithFallback(raw, CommentsListSchema, [], {
|
||
endpoint: "GET /api/issues/:id/comments",
|
||
});
|
||
}
|
||
|
||
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
||
return this.fetch(`/api/issues/${issueId}/comments`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
content,
|
||
type: type ?? "comment",
|
||
...(parentId ? { parent_id: parentId } : {}),
|
||
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
|
||
}),
|
||
});
|
||
}
|
||
|
||
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/issues/${issueId}/timeline`,
|
||
);
|
||
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
|
||
endpoint: "GET /api/issues/:id/timeline",
|
||
});
|
||
}
|
||
|
||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||
return this.fetch("/api/assignee-frequency");
|
||
}
|
||
|
||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||
});
|
||
}
|
||
|
||
async deleteComment(commentId: string): Promise<void> {
|
||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async resolveComment(commentId: string): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
|
||
}
|
||
|
||
async unresolveComment(commentId: string): Promise<Comment> {
|
||
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
|
||
}
|
||
|
||
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
|
||
return this.fetch(`/api/comments/${commentId}/reactions`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ emoji }),
|
||
});
|
||
}
|
||
|
||
async removeReaction(commentId: string, emoji: string): Promise<void> {
|
||
await this.fetch(`/api/comments/${commentId}/reactions`, {
|
||
method: "DELETE",
|
||
body: JSON.stringify({ emoji }),
|
||
});
|
||
}
|
||
|
||
async addIssueReaction(issueId: string, emoji: string): Promise<IssueReaction> {
|
||
return this.fetch(`/api/issues/${issueId}/reactions`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ emoji }),
|
||
});
|
||
}
|
||
|
||
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
|
||
await this.fetch(`/api/issues/${issueId}/reactions`, {
|
||
method: "DELETE",
|
||
body: JSON.stringify({ emoji }),
|
||
});
|
||
}
|
||
|
||
// Subscribers
|
||
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
|
||
return parseWithFallback(raw, SubscribersListSchema, [], {
|
||
endpoint: "GET /api/issues/:id/subscribers",
|
||
});
|
||
}
|
||
|
||
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
||
const body: Record<string, string> = {};
|
||
if (userId) body.user_id = userId;
|
||
if (userType) body.user_type = userType;
|
||
await this.fetch(`/api/issues/${issueId}/subscribe`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async unsubscribeFromIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
||
const body: Record<string, string> = {};
|
||
if (userId) body.user_id = userId;
|
||
if (userType) body.user_type = userType;
|
||
await this.fetch(`/api/issues/${issueId}/unsubscribe`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
// Agents
|
||
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
|
||
const search = new URLSearchParams();
|
||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||
if (params?.include_archived) search.set("include_archived", "true");
|
||
return this.fetch(`/api/agents?${search}`);
|
||
}
|
||
|
||
async getAgent(id: string): Promise<Agent> {
|
||
return this.fetch(`/api/agents/${id}`);
|
||
}
|
||
|
||
async createAgent(data: CreateAgentRequest): Promise<Agent> {
|
||
return this.fetch("/api/agents", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
|
||
const raw = await this.fetch<unknown>("/api/agent-templates");
|
||
return parseWithFallback(
|
||
raw,
|
||
AgentTemplateSummaryListSchema,
|
||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||
{ endpoint: "GET /api/agent-templates" },
|
||
);
|
||
}
|
||
|
||
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
|
||
const raw = await this.fetch<unknown>(
|
||
`/api/agent-templates/${encodeURIComponent(slug)}`,
|
||
);
|
||
// Round-trip the requested slug into the fallback so a malformed
|
||
// detail response still produces a navigable record matching the URL
|
||
// the user clicked.
|
||
return parseWithFallback(
|
||
raw,
|
||
AgentTemplateSchema,
|
||
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
|
||
{ endpoint: "GET /api/agent-templates/:slug" },
|
||
);
|
||
}
|
||
|
||
/** Creates an agent from a curated template. The server fetches every
|
||
* referenced skill URL in parallel, materializes them into the workspace
|
||
* (find-or-create by name), and writes the agent + skill bindings in a
|
||
* single transaction. On any upstream fetch failure, the entire write is
|
||
* rolled back and the API returns 422 with `failed_urls`. */
|
||
async createAgentFromTemplate(
|
||
data: CreateAgentFromTemplateRequest,
|
||
): Promise<CreateAgentFromTemplateResponse> {
|
||
const raw = await this.fetch<unknown>("/api/agents/from-template", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
return parseWithFallback(
|
||
raw,
|
||
CreateAgentFromTemplateResponseSchema,
|
||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||
{ endpoint: "POST /api/agents/from-template" },
|
||
);
|
||
}
|
||
|
||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||
return this.fetch(`/api/agents/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async archiveAgent(id: string): Promise<Agent> {
|
||
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
|
||
}
|
||
|
||
async restoreAgent(id: string): Promise<Agent> {
|
||
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
|
||
}
|
||
|
||
// Bulk-cancel every active task (queued/dispatched/running) for the agent.
|
||
// Permission: agent owner or workspace admin/owner. Server returns the
|
||
// count of cancelled rows; broadcasts task:cancelled for each so other
|
||
// surfaces can clear their live cards.
|
||
async cancelAgentTasks(id: string): Promise<{ cancelled: number }> {
|
||
return this.fetch(`/api/agents/${id}/cancel-tasks`, { method: "POST" });
|
||
}
|
||
|
||
async listRuntimes(params?: { workspace_id?: string; owner?: "me" }): Promise<AgentRuntime[]> {
|
||
const search = new URLSearchParams();
|
||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||
if (params?.owner) search.set("owner", params.owner);
|
||
return this.fetch(`/api/runtimes?${search}`);
|
||
}
|
||
|
||
async 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(nodeId: string): Promise<void> {
|
||
await this.fetchRaw("/api/cloud-runtime/nodes", {
|
||
method: "DELETE",
|
||
body: JSON.stringify({ id: nodeId }),
|
||
extraHeaders: { "Content-Type": "application/json" },
|
||
});
|
||
}
|
||
|
||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async updateRuntime(
|
||
runtimeId: string,
|
||
patch: { visibility?: "private" | "public" },
|
||
): Promise<AgentRuntime> {
|
||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
async markAllInboxRead(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||
}
|
||
|
||
async archiveAllInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
||
}
|
||
|
||
async archiveAllReadInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
||
}
|
||
|
||
async archiveCompletedInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||
}
|
||
|
||
// Notification preferences
|
||
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||
return this.fetch("/api/notification-preferences");
|
||
}
|
||
|
||
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
||
return this.fetch("/api/notification-preferences", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ preferences }),
|
||
});
|
||
}
|
||
|
||
// App Config
|
||
async getConfig(): Promise<{
|
||
cdn_domain: string;
|
||
allow_signup: boolean;
|
||
google_client_id?: string;
|
||
posthog_key?: string;
|
||
posthog_host?: string;
|
||
analytics_environment?: string;
|
||
}> {
|
||
return this.fetch("/api/config");
|
||
}
|
||
|
||
// Workspaces
|
||
async listWorkspaces(): Promise<Workspace[]> {
|
||
return this.fetch("/api/workspaces");
|
||
}
|
||
|
||
async getWorkspace(id: string): Promise<Workspace> {
|
||
return this.fetch(`/api/workspaces/${id}`);
|
||
}
|
||
|
||
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
||
return this.fetch("/api/workspaces", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: 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),
|
||
});
|
||
}
|
||
|
||
// Personal Access Tokens
|
||
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
||
return this.fetch("/api/tokens");
|
||
}
|
||
|
||
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
||
return this.fetch("/api/tokens", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async revokePersonalAccessToken(id: string): Promise<void> {
|
||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// File Upload & Attachments
|
||
async uploadFile(
|
||
file: File,
|
||
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
|
||
): Promise<Attachment> {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
||
|
||
const rid = createRequestId();
|
||
const start = Date.now();
|
||
this.logger.info("→ POST /api/upload-file", { rid });
|
||
|
||
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
||
method: "POST",
|
||
headers: this.authHeaders(),
|
||
body: formData,
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) this.handleUnauthorized();
|
||
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
||
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||
throw new Error(message);
|
||
}
|
||
|
||
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||
const raw = (await res.json()) as unknown;
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "POST /api/upload-file",
|
||
});
|
||
}
|
||
|
||
// Chat Sessions
|
||
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
|
||
const query = params?.status ? `?status=${params.status}` : "";
|
||
return this.fetch(`/api/chat/sessions${query}`);
|
||
}
|
||
|
||
async getChatSession(id: string): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}`);
|
||
}
|
||
|
||
async createChatSession(data: { agent_id: string; title?: string }): Promise<ChatSession> {
|
||
return this.fetch("/api/chat/sessions", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteChatSession(id: string): Promise<void> {
|
||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||
}
|
||
|
||
async sendChatMessage(
|
||
sessionId: string,
|
||
content: string,
|
||
attachmentIds?: string[],
|
||
): Promise<SendChatMessageResponse> {
|
||
const body: { content: string; attachment_ids?: string[] } = { content };
|
||
if (attachmentIds && attachmentIds.length > 0) {
|
||
body.attachment_ids = attachmentIds;
|
||
}
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||
}
|
||
|
||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||
return this.fetch(`/api/chat/pending-tasks`);
|
||
}
|
||
|
||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||
}
|
||
|
||
async cancelTaskById(taskId: string): Promise<void> {
|
||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||
}
|
||
|
||
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||
}
|
||
|
||
// Fetches a fresh attachment metadata record. The server re-signs
|
||
// `download_url` on every call (30 min expiry), so the click-time
|
||
// download flow uses this endpoint to avoid handing the user a stale
|
||
// signed URL cached in TanStack Query.
|
||
async getAttachment(id: string): Promise<Attachment> {
|
||
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "GET /api/attachments/{id}",
|
||
});
|
||
}
|
||
|
||
async deleteAttachment(id: string): Promise<void> {
|
||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// Fetches the raw bytes of a text-previewable attachment.
|
||
//
|
||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||
// of which would otherwise prevent the renderer from getting the body.
|
||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||
// the preview dispatcher can choose between markdown / html / plain code.
|
||
//
|
||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||
// the modal can render specific fallbacks instead of generic failure.
|
||
async getAttachmentTextContent(
|
||
id: string,
|
||
): Promise<{ text: string; originalContentType: string }> {
|
||
let res: Response;
|
||
try {
|
||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||
} catch (err) {
|
||
if (err instanceof ApiError) {
|
||
if (err.status === 413) throw new PreviewTooLargeError();
|
||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||
}
|
||
throw err;
|
||
}
|
||
return {
|
||
text: await res.text(),
|
||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||
};
|
||
}
|
||
|
||
// Projects
|
||
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.status) search.set("status", params.status);
|
||
return this.fetch(`/api/projects?${search}`);
|
||
}
|
||
|
||
async getProject(id: string): Promise<Project> {
|
||
return this.fetch(`/api/projects/${id}`);
|
||
}
|
||
|
||
async createProject(data: CreateProjectRequest): Promise<Project> {
|
||
return this.fetch("/api/projects", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||
return this.fetch(`/api/projects/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteProject(id: string): Promise<void> {
|
||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// Project resources
|
||
async listProjectResources(
|
||
projectId: string,
|
||
): Promise<ListProjectResourcesResponse> {
|
||
return this.fetch(`/api/projects/${projectId}/resources`);
|
||
}
|
||
|
||
async createProjectResource(
|
||
projectId: string,
|
||
data: CreateProjectResourceRequest,
|
||
): Promise<ProjectResource> {
|
||
return this.fetch(`/api/projects/${projectId}/resources`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteProjectResource(
|
||
projectId: string,
|
||
resourceId: string,
|
||
): Promise<void> {
|
||
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Labels
|
||
async listLabels(): Promise<ListLabelsResponse> {
|
||
return this.fetch(`/api/labels`);
|
||
}
|
||
|
||
async getLabel(id: string): Promise<Label> {
|
||
return this.fetch(`/api/labels/${id}`);
|
||
}
|
||
|
||
async createLabel(data: CreateLabelRequest): Promise<Label> {
|
||
return this.fetch(`/api/labels`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
|
||
return this.fetch(`/api/labels/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteLabel(id: string): Promise<void> {
|
||
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels`);
|
||
}
|
||
|
||
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ label_id: labelId }),
|
||
});
|
||
}
|
||
|
||
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Pins
|
||
async listPins(): Promise<PinnedItem[]> {
|
||
return this.fetch("/api/pins");
|
||
}
|
||
|
||
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
||
return this.fetch("/api/pins", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
||
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
||
await this.fetch("/api/pins/reorder", {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Squads
|
||
async listSquads(): Promise<Squad[]> {
|
||
return this.fetch(`/api/squads`);
|
||
}
|
||
|
||
async getSquad(id: string): Promise<Squad> {
|
||
return this.fetch(`/api/squads/${id}`);
|
||
}
|
||
|
||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async deleteSquad(id: string): Promise<void> {
|
||
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
||
return this.fetch(`/api/squads/${squadId}/members`);
|
||
}
|
||
|
||
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
||
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
||
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
||
}
|
||
|
||
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||
}
|
||
|
||
// 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);
|
||
return this.fetch(`/api/autopilots?${search}`);
|
||
}
|
||
|
||
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
|
||
return this.fetch(`/api/autopilots/${id}`);
|
||
}
|
||
|
||
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
|
||
return this.fetch("/api/autopilots", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
|
||
return this.fetch(`/api/autopilots/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteAutopilot(id: string): Promise<void> {
|
||
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async triggerAutopilot(id: string): Promise<AutopilotRun> {
|
||
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
|
||
}
|
||
|
||
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.limit) search.set("limit", params.limit.toString());
|
||
if (params?.offset) search.set("offset", params.offset.toString());
|
||
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
||
}
|
||
|
||
// 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`);
|
||
}
|
||
}
|