mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(server): add webhook trigger DB migration + sqlc queries
Lays the foundation for webhook autopilot triggers:
- partial unique index on autopilot_trigger.webhook_token (kind=webhook only)
so the public ingress route can resolve a trigger in O(1)
- GetWebhookTriggerByToken / TouchAutopilotTriggerFiredAt /
RotateAutopilotTriggerWebhookToken / SetAutopilotTriggerWebhookToken
queries, regenerated with sqlc
* feat(server): webhook token generator + payload normalizer
Two pure helpers for the webhook autopilot work:
- generateWebhookToken: 32 random bytes -> base64-url, "awt_" prefix.
256 bits of entropy keeps brute-force off the table; the prefix makes
leaked tokens recognisable in logs.
- normalizeWebhookPayload: turns arbitrary JSON into the WebhookEnvelope
shape (event/eventPayload/request) used by trigger_payload. Header- and
body-based event inference covers GitHub, GitLab, X-Event-Type, and
caller-provided envelopes; scalar/empty/invalid bodies are rejected so
the handler can answer 400.
* feat(server): generate webhook tokens and expose rotate endpoint
- New handler.Config.PublicURL fed by MULTICA_PUBLIC_URL env so
/api/autopilots/.../triggers responses can include an absolute
webhook_url alongside the always-present webhook_path.
- CreateAutopilotTrigger now mints a webhook_token via crypto/rand
for kind=webhook and ignores cron/timezone for non-schedule kinds.
api triggers stay accepted-but-inert per PLAN.md.
- New POST /api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token
protected by the existing workspace auth group; old tokens stop
working immediately because the unique-index lookup keys on the
current row value.
* feat(server): public webhook ingress route + per-token rate limiter
- New POST /api/webhooks/autopilots/{token} route, mounted outside the
authenticated group: the path token is the credential. Workspace
context is derived from the joined autopilot row, never headers.
- Body capped at 256 KiB via http.MaxBytesReader; oversized payloads
return 413 mid-read instead of being fully buffered.
- Disabled triggers / paused / archived autopilots return
200 {"status":"ignored"} so providers stop retrying.
- Skipped-runtime dispatches surface 200 {"status":"skipped"} with the
reason from the autopilot service's pre-flight admission check.
- WebhookRateLimiter interface with sliding-window in-memory + Redis
Lua-script implementations. Default 60 req/min per token. Test
coverage on the in-memory path; Redis variant fails open on cache
errors so a Redis hiccup never blocks ingress.
- Integration tests exercise token generation, dispatch, payload
envelope persistence, GitHub-header inference, paused/disabled
short-circuits, oversized rejection, and rotate-then-old-token-404.
* feat(server): include webhook payload in create_issue description
When an autopilot run is triggered by a webhook and execution_mode is
create_issue, the agent only sees the issue body — never the run's
trigger_payload. Append a 'Webhook event:' line and a fenced JSON block
with the normalized eventPayload so the agent has the inbound context
inline. Schedule / manual runs are unchanged.
Tests cover:
- schedule path keeps existing italic note, no webhook block
- webhook path emits event line + payload block, italic before block
- non-envelope JSON falls back to raw body (defensive)
- non-webhook source with payload still gets no webhook block
* feat(core): types, API client and mutations for webhook triggers
- AutopilotRunStatus gains 'skipped' so the run-list UI handles the
admission-skipped state explicitly instead of falling through to a
generic case (the backend already emits it via MUL-1899).
- AutopilotTrigger picks up optional webhook_path / webhook_url. Both
are optional so older self-hosted servers that pre-date this change
still parse cleanly.
- buildAutopilotWebhookUrl helper composes a usable absolute URL with
the priority webhook_url > apiBaseUrl + path > origin + path > path.
Tested with seven cases covering each branch.
- ApiClient.rotateAutopilotTriggerWebhookToken posts to
/api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token; the
HTTP-contract test pins URL + method.
- useRotateAutopilotTriggerWebhookToken mutation invalidates
autopilotKeys.detail on settle, mirroring the existing trigger-mutation
pattern.
* feat(views): webhook trigger UI in Add Trigger dialog and trigger row
Add Trigger dialog gains a Schedule/Webhook segmented toggle:
- Schedule reuses TriggerConfigSection unchanged.
- Webhook hides the cron config and shows a help line; the trigger is
created with kind=webhook and the URL is generated server-side.
- Toast text differentiates schedule vs webhook on success.
TriggerRow grows a webhook branch:
- Webhook icon, kind translated via trigger_kind.
- URL shown in a truncating monospace pill, with copy + rotate
buttons. Copy uses navigator.clipboard with toast feedback; rotate
uses an AlertDialog confirm because the old URL stops working
immediately.
- api triggers render a Deprecated badge and skip URL/copy/rotate
affordances.
RunRow gains a 'skipped' RUN_VISUAL entry (muted dash) so admission-
skipped runs don't fall through to a generic case. Source label uses the
new run_source i18n key instead of capitalize.
Locales: en + zh-Hans gain run_status.skipped, run_source.*,
trigger_kind.*, trigger_row.{copy_url,rotate_url,*_confirm_*,toast_*},
add_trigger_dialog.{type_*,webhook_help,toast_added_{schedule,webhook}}.
* feat(cli): support webhook trigger creation and URL rotation
- multica autopilot trigger-add now takes --kind schedule|webhook
(default schedule for backward compatibility). For webhook it skips
--cron / --timezone validation and prints the resulting webhook URL,
preferring the server-provided webhook_url and falling back to
client.BaseURL + webhook_path.
- New multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>
command for rotating the bearer URL of a webhook trigger.
* docs(autopilots): add webhook trigger guide (en + zh)
Replaces the 'Webhook and API triggers are not available yet' section
with end-to-end webhook documentation: how the URL is generated, what
payload shapes are accepted, the inferred-event rules, the bearer-secret
warning + rotate flow, status-code semantics for accepted/skipped/
ignored/4xx/5xx outcomes, and the MULTICA_PUBLIC_URL self-host
configuration.
Run history list now mentions skipped status. The 'unavailable
features' section narrows to api-kind triggers, HMAC signing, IP
allowlists, and provider presets.
* feat(views): add Schedule/Webhook toggle to the create autopilot dialog
Closes the gap where a brand-new autopilot could only be created with a
schedule trigger. The right-column config now has a Trigger section
with a segmented Schedule/Webhook control:
- Schedule keeps the existing cron/timezone UI.
- Webhook hides the cron UI and shows a help line; on submit, a
kind=webhook trigger is created right after the autopilot.
In edit mode the toggle is intentionally hidden (PLAN.md treats trigger-
type changes as delete-old + create-new, not in-place updates), but the
panel still picks the right kind based on props.triggers[0].kind so a
webhook autopilot doesn't render an irrelevant cron form.
Locales: section_trigger_kind, trigger_kind_{schedule,webhook},
section_webhook, webhook_help_{create,edit} added in en + zh-Hans.
* feat(views): show webhook URL inline after creating a webhook autopilot
After a successful create with kind=webhook, the dialog stays open and
swaps to a confirmation panel showing the freshly minted URL with a
copy button + 'Treat this URL like a password' warning + Done button.
Avoids the friction of "create the autopilot, then go find it in the
list, click in, scroll to triggers, copy URL."
Locales: dialog.webhook_created_{title,description,warning,done} added
in en + zh-Hans.
Schedule create flow is unchanged (toast + close). The success panel is
gated on the trigger returned from the create mutation, so a partial
failure (autopilot created, trigger creation errored) still falls
through to the toast_create_partial path.
* feat(views): show webhook payload in run detail dialog
The agent transcript dialog now accepts an optional headerSlot that
sits above the event list. The autopilot RunRow drops a
WebhookPayloadPreview into that slot when the run came from a webhook
and trigger_payload is non-empty.
The preview is collapsed by default (the transcript itself is the main
event), shows the inferred event name + receivedAt in the header, and
reveals the eventPayload as pretty-printed JSON with a copy button on
expand. Falls back gracefully if the row's trigger_payload doesn't
match the WebhookEnvelope shape — the whole value is shown instead so
nothing is hidden.
Closes the "agent didn't echo the payload, now I can't see what
triggered the run" gap. PLAN.md tracked this as
"Payload preview in run history" under follow-ups.
Locales: webhook_payload.{label, unknown_event, payload, content_type,
copy, copied, copied_short, copy_failed} added in en + zh-Hans.
* chore(server): wire MULTICA_PUBLIC_URL through self-host compose
Two small follow-ups split out of the webhook trigger PR:
- docker-compose.selfhost.yml passes MULTICA_PUBLIC_URL into the
backend container so a self-hosted deployment behind a real domain
gets absolute webhook URLs in the trigger response. Documented in
.env.example with the rationale for not deriving the public host
from request headers.
- Drop a duplicated 'invalid json:' prefix in the webhook ingress
400 error path. normalizeWebhookPayload already prefixes its
errors, so the handler doesn't need to re-prefix.
* fix(migrations): renumber webhook trigger migration 081 → 089 to avoid collision
The branch's 081_autopilot_webhook_triggers.{up,down}.sql collided
numerically with 081_runtime_timezone.{up,down}.sql that landed on
main, making migration apply order undefined. Renumber to 089 so the
file slots after the latest main migration (088_squad_instructions).
The SQL itself doesn't conflict — it only creates a partial unique
index on autopilot_trigger.webhook_token — but the duplicate prefix
is what the migration runner sees, so the filename must move.
* fix(autopilot-webhook): address PR review blocking issues
- Redact bearer tokens from request logs: paths matching
/api/webhooks/autopilots/<token> now log "[redacted]" instead of the
token. The resolved trigger ID is plumbed via context so audit lines
stay useful for debugging. (Review item Blocking #1.)
- Distinguish pgx.ErrNoRows from transient DB errors in token lookup:
no-row stays 404 (so providers don't retry on a deleted webhook),
other errors return 500 (which providers DO retry, avoiding silent
drops on DB blips). (Review item Blocking #2.)
- Add per-IP sliding-window rate limiter that runs BEFORE the token
lookup, so spraying random tokens can no longer probe the
autopilot_trigger index unboundedly. Reuses the existing Lua script
with a separate Redis key namespace; falls open on Redis errors.
Default budget 30 req/min/IP. (Review item Blocking #3.)
The webhook handler now applies the gates in the order: per-IP rate
limit → token lookup → per-token rate limit → handler logic.
* fix(autopilot): atomic webhook trigger creation + strict kind/timezone validation
- Mint the webhook bearer token BEFORE the INSERT and pass it via
CreateAutopilotTriggerParams so the row never exists in a half-written
kind=webhook + webhook_token=NULL state. On the (vanishingly rare)
unique-index collision the whole INSERT is retried with a fresh token
— no UPDATE second step. Removes the now-dead attachFreshWebhookToken
helper. (Review item Recommended #4.)
- Add new GET /api/autopilots/{id}/runs/{runId} endpoint that returns a
single run including the full trigger_payload. The list response is
now slim (omits trigger_payload) so worst-case payload size drops
from ~5 MB to ~5 KB. (Review item Recommended #5, server side.)
- Reject kind=api with 400 ("kind=api is deprecated; use schedule or
webhook") and reject kind=webhook with --timezone with 400 — both
surfaces stragglers loudly instead of silently dropping fields.
CLI mirrors the check so --timezone with --kind webhook errors
client-side. (Review nits.)
- Add --yes (-y) flag and an interactive y/N confirmation prompt to
`multica autopilot trigger-rotate-url` so the destructive rotate
matches the UI's AlertDialog safety. (Review item Recommended #6.)
* fix(views): fetch webhook payload on-demand and truncate at 4 KiB
- Add useAutopilotRun query hook + getAutopilotRun API client method
paired with the new server endpoint. The run-detail dialog now mounts
a WebhookPayloadSlot that fetches the full run (incl. trigger_payload)
lazily — list responses no longer carry up to 256 KiB × N runs of
envelope data.
- WebhookPayloadPreview truncates its in-DOM <pre> at 4 KiB with a
localized marker so jank-y machines aren't asked to render a 256 KiB
JSON blob. The Copy button still yields the full string.
- Adds the truncated_marker i18n string to en + zh-Hans.
Review items Recommended #5 (frontend) and a nit on the preview's
unbounded <pre>.
* test(autopilot-webhook): close coverage gaps flagged in PR review
- request_logger: redactWebhookPath unit tests + integration test
proving the bearer token never lands in slog output, plus the
webhook_trigger_id context plumbing.
- autopilot_webhook_handler: empty body → 400, archived autopilot →
200 ignored, per-IP rate limiter trips before DB lookup, kind=api
and webhook+timezone are rejected at 400, slim list + full detail
endpoint round-trip.
- webhook_rate_limiter: Lua script structure guard (catches reordering
even without a live Redis), plus live-Redis tests for both per-token
and per-IP limiters (REDIS_TEST_URL gated, matching the existing
Redis test pattern in the package).
- WebhookPayloadPreview: envelope rendering, fallback shape, and the
>4 KiB truncation path with full-payload-on-Copy guarantee.
Two branches are documented as code-review-protected rather than
covered by tests: the 500-on-DB-error path requires injecting a stub
Queries (no interface here), and the cross-workspace defense-in-depth
check is unreachable from valid SQL state.
* fix(middleware): SetWebhookTriggerID must mutate request in place
The round-1 helper returned a fresh *http.Request from WithContext, and
the webhook handler did `r = SetWebhookTriggerID(r, ...)`. That swaps
the handler's local pointer but doesn't propagate the new context back
to RequestLogger, which is still holding the original *http.Request —
so the audit line never actually included webhook_trigger_id in
production. The round-1 test happened to pass because it pre-stashed
the value on the request before calling ServeHTTP, bypassing the bug
it was meant to verify.
Switch to in-place mutation via `*r = *r.WithContext(...)` so the
wrapping middleware sees the new context after next.ServeHTTP returns,
and update the test to exercise the real call pattern (set the context
from inside the handler, assert the surrounding logger reads it).
Verified live: an accepted webhook now logs
path=/api/webhooks/autopilots/[redacted] webhook_trigger_id=<uuid>
* fix(autopilot-webhook): symmetric ErrNoRows split + trusted-proxy gate
Round-2 review (Bohan-J, PR #2348 follow-up):
- Must-fix #1: the second lookup at autopilot_webhook.go:258
(GetAutopilot after the token resolves) was folding every error into
404. A transient DB blip would tell a webhook sender "not found" and
it would never retry. Apply the same errors.Is(err, pgx.ErrNoRows)
→ 404 / else → 500 split as the first lookup got in round 1.
- Must-fix #2: clientIPForRateLimit was honoring X-Forwarded-For /
X-Real-IP from any caller. An attacker spraying random tokens could
just rotate the XFF header and the per-IP bucket became per-request,
so the limiter that's specifically supposed to gate spraying before
it hits the DB unique index was bypassed.
New shape — matches Bohan's suggestion exactly:
* Default: r.RemoteAddr only, headers ignored.
* Operator opt-in via MULTICA_TRUSTED_PROXIES (comma-separated
CIDRs). XFF/X-Real-IP are honored only when r.RemoteAddr is
inside one of the listed prefixes; otherwise they're dropped.
Wired through .env.example and docker-compose.selfhost.yml so
self-host operators can configure their reverse-proxy's CIDR.
Invalid CIDRs in the env var are dropped with a single slog.Warn at
startup rather than crashing the server. Uses net/netip (stdlib,
value-typed) for parsing and containment checks.
Verified live on the rebuilt self-host backend: a 35-request spray
from one source with rotating XFF gets the expected 30× 404 + 5× 429,
proving the per-IP bucket is keyed on the real connection IP.
* fix(autopilot): reject cron/timezone PATCH on non-schedule triggers
Round-2 review should-fix. CreateAutopilotTrigger already 400s on
kind=webhook + timezone/cron_expression, but UpdateAutopilotTrigger
silently wrote those fields regardless of prev.Kind. The values then
sat in the DB visible to nobody and read by nothing — a back door that
left the API contract fuzzy across create vs update.
Mirror the create-path discipline: after loading prev, if prev.Kind
!= "schedule" and the PATCH body sets cron_expression or timezone,
return 400 with a clear message. enabled and label remain accepted on
every kind.
The existing prev.Kind == "schedule" guard on next_run_at recompute
stays as belt-and-braces, but with this gate in place the recompute
branch is now reachable only for the kind it was meant for.
* test(autopilot-webhook): close round-2 coverage gaps
- IPRateLimitNotBypassedByXFFSpoof: drives the must-fix #2 invariant
by rotating XFF across three calls from the same RemoteAddr and
asserting the third gets 429. Pre-round-2 this test would have
passed for the wrong reason (limiter trusted XFF, so per-bucket
collision was incidental); now it pins the bypass-closed property.
- IPRateLimitReturns429BeforeDBLookup: updated to set RemoteAddr
explicitly and drop the XFF header it was leaning on. With
TrustedProxies empty (test default) the limiter keys on the real
connection IP, which is what the test wants to assert anyway.
- UpdateAutopilotTrigger_RejectsCronExpressionOnWebhookKind +
UpdateAutopilotTrigger_RejectsTimezoneOnWebhookKind: drive the
round-2 should-fix from the handler boundary.
- UpdateAutopilotTrigger_AcceptsEnabledAndLabelOnWebhookKind: counter
test so a regression to a blanket reject is caught.
* fix(migrations): bump webhook trigger migration 089 → 091
origin/main added 089_squad_no_action_activity_index (and 090_task_is_leader)
since our last rebase, re-colliding with our 089_autopilot_webhook_triggers.
Bump to 091 so the filename ordering is unambiguous again. The SQL is
unchanged — same partial unique index on autopilot_trigger.webhook_token —
only the filename moves.
* fix(views): dedupe skipped icon in autopilot RUN_VISUAL after rebase
The rebase against origin/main merged main's add of `Ban` for the
skipped status next to our round-1 `MinusCircle` entry, leaving the
RUN_VISUAL map with two `skipped` keys (only the last would have been
read at runtime, and MinusCircle had been dropped from the imports
during conflict resolution — so the file would not compile).
Keep main's `Ban` icon (latest design) and a single `skipped` entry.
Carry over the round-1 comment about why the muted styling matters
for failure-ratio readability.
---------
Co-authored-by: Kerim Incedayi <kerim.incedayi@digitalchargingsolutions.com>
1635 lines
55 KiB
TypeScript
1635 lines
55 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,
|
||
NotificationPreferenceResponse,
|
||
NotificationPreferences,
|
||
GitHubPullRequest,
|
||
ListGitHubInstallationsResponse,
|
||
GitHubConnectResponse,
|
||
Squad,
|
||
SquadMember,
|
||
} from "../types";
|
||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||
import { type Logger, noopLogger } from "../logger";
|
||
import { createRequestId } from "../utils";
|
||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||
import { parseWithFallback } from "./schema";
|
||
import {
|
||
AgentTemplateSchema,
|
||
AgentTemplateSummaryListSchema,
|
||
AttachmentResponseSchema,
|
||
ChildIssuesResponseSchema,
|
||
CommentsListSchema,
|
||
CreateAgentFromTemplateResponseSchema,
|
||
DashboardAgentRunTimeListSchema,
|
||
DashboardRunTimeDailyListSchema,
|
||
DashboardUsageByAgentListSchema,
|
||
DashboardUsageDailyListSchema,
|
||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||
EMPTY_ATTACHMENT,
|
||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||
EMPTY_LIST_ISSUES_RESPONSE,
|
||
EMPTY_TIMELINE_ENTRIES,
|
||
GroupedIssuesResponseSchema,
|
||
ListIssuesResponseSchema,
|
||
SubscribersListSchema,
|
||
TimelineEntriesSchema,
|
||
} from "./schemas";
|
||
|
||
/** Identifies the calling client to the server.
|
||
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
||
* X-Client-OS so the backend can log, gate, or split metrics by client.
|
||
* See server/internal/middleware/client.go for the receiving end. */
|
||
export interface ApiClientIdentity {
|
||
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
|
||
platform?: string;
|
||
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
|
||
version?: string;
|
||
/** Operating system the client is running on: "macos" | "windows" | "linux". */
|
||
os?: string;
|
||
}
|
||
|
||
export interface ApiClientOptions {
|
||
logger?: Logger;
|
||
onUnauthorized?: () => void;
|
||
/** Identifies the client to the server. Sent as X-Client-* headers. */
|
||
identity?: ApiClientIdentity;
|
||
}
|
||
|
||
export interface LoginResponse {
|
||
token: string;
|
||
user: User;
|
||
}
|
||
|
||
// --- Starter content (post-onboarding import) -----------------------------
|
||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||
//
|
||
// The client sends both branches of sub-issues and an unbound welcome
|
||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||
// the branch by inspecting the workspace's agent list inside the
|
||
// import transaction. This removes the client as a trusted decider —
|
||
// even if the client has a stale agent cache or lies, the server uses
|
||
// the DB as source of truth.
|
||
|
||
export interface ImportStarterIssuePayload {
|
||
title: string;
|
||
description: string;
|
||
status: string;
|
||
priority: string;
|
||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||
* as assignee when true. No member_id is threaded through. */
|
||
assign_to_self: boolean;
|
||
}
|
||
|
||
export interface ImportStarterWelcomeIssueTemplate {
|
||
title: string;
|
||
description: string;
|
||
/** Defaults to "high" on server when empty. */
|
||
priority: string;
|
||
}
|
||
|
||
export interface ImportStarterContentPayload {
|
||
workspace_id: string;
|
||
project: { title: string; description: string; icon: string };
|
||
/** Always sent. Server creates it only when an agent exists in the
|
||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||
/** Used when the workspace has at least one agent. */
|
||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||
/** Used when the workspace has zero agents. */
|
||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||
}
|
||
|
||
export interface ImportStarterContentResponse {
|
||
user: User;
|
||
project_id: string;
|
||
/** Non-null when server took the agent-guided branch. */
|
||
welcome_issue_id: string | null;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly statusText: string;
|
||
// Raw decoded JSON body (when the server returned one). Carries structured
|
||
// error fields like `code` so callers can branch on machine-readable
|
||
// identifiers instead of pattern-matching the human-readable message.
|
||
readonly body?: unknown;
|
||
|
||
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
this.status = status;
|
||
this.statusText = statusText;
|
||
this.body = body;
|
||
}
|
||
}
|
||
|
||
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
||
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
||
// download" affordance with the Download CTA still available.
|
||
export class PreviewTooLargeError extends Error {
|
||
constructor() {
|
||
super("attachment too large for inline preview");
|
||
this.name = "PreviewTooLargeError";
|
||
}
|
||
}
|
||
|
||
// Thrown by getAttachmentTextContent when the server's text whitelist
|
||
// rejects the content type. Normally the client's isPreviewable() guard
|
||
// catches this earlier, but the two whitelists can drift — surfacing the
|
||
// 415 as a typed error makes the drift visible.
|
||
export class PreviewUnsupportedError extends Error {
|
||
constructor() {
|
||
super("attachment type not supported for inline preview");
|
||
this.name = "PreviewUnsupportedError";
|
||
}
|
||
}
|
||
|
||
export class ApiClient {
|
||
private baseUrl: string;
|
||
private token: string | null = null;
|
||
private logger: Logger;
|
||
private options: ApiClientOptions;
|
||
|
||
constructor(baseUrl: string, options?: ApiClientOptions) {
|
||
this.baseUrl = baseUrl;
|
||
this.options = options ?? {};
|
||
this.logger = options?.logger ?? noopLogger;
|
||
}
|
||
|
||
getBaseUrl(): string {
|
||
return this.baseUrl;
|
||
}
|
||
|
||
setToken(token: string | null) {
|
||
this.token = token;
|
||
}
|
||
|
||
private readCsrfToken(): string | null {
|
||
if (typeof document === "undefined") return null;
|
||
const match = document.cookie
|
||
.split("; ")
|
||
.find((c) => c.startsWith("multica_csrf="));
|
||
return match ? match.split("=")[1] ?? null : null;
|
||
}
|
||
|
||
private authHeaders(): Record<string, string> {
|
||
const headers: Record<string, string> = {};
|
||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||
const slug = getCurrentSlug();
|
||
if (slug) headers["X-Workspace-Slug"] = slug;
|
||
const csrf = this.readCsrfToken();
|
||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||
const id = this.options.identity;
|
||
if (id?.platform) headers["X-Client-Platform"] = id.platform;
|
||
if (id?.version) headers["X-Client-Version"] = id.version;
|
||
if (id?.os) headers["X-Client-OS"] = id.os;
|
||
return headers;
|
||
}
|
||
|
||
private handleUnauthorized() {
|
||
this.token = null;
|
||
// Workspace id is owned by the URL-driven workspace-storage singleton
|
||
// (set by [workspaceSlug]/layout.tsx). On 401, the auth flow navigates
|
||
// to /login which leaves the workspace route, and the next workspace
|
||
// entry will overwrite the id. No clear needed here.
|
||
this.options.onUnauthorized?.();
|
||
}
|
||
|
||
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
||
try {
|
||
const data = await res.json() as { error?: string };
|
||
if (typeof data.error === "string" && data.error) return data.error;
|
||
} catch {
|
||
// Ignore non-JSON error bodies.
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
// Reads the response body once for both human-readable error message and
|
||
// structured fields. The Response stream can only be consumed once, so
|
||
// both pieces have to come from a single read.
|
||
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
||
try {
|
||
const data = await res.json() as { error?: string };
|
||
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
||
return { message, body: data };
|
||
} catch {
|
||
return { message: fallback, body: undefined };
|
||
}
|
||
}
|
||
|
||
// Sends the request with the standard headers (auth, CSRF, request id,
|
||
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
||
// structured ApiError, status-aware log level). Returns the raw Response so
|
||
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
||
// path, plain text for the attachment-preview proxy, etc.
|
||
private async fetchRaw(
|
||
path: string,
|
||
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
||
): Promise<Response> {
|
||
const rid = createRequestId();
|
||
const start = Date.now();
|
||
const method = init?.method ?? "GET";
|
||
|
||
const headers: Record<string, string> = {
|
||
"X-Request-ID": rid,
|
||
...this.authHeaders(),
|
||
...(init?.extraHeaders ?? {}),
|
||
...((init?.headers as Record<string, string>) ?? {}),
|
||
};
|
||
|
||
this.logger.info(`→ ${method} ${path}`, { rid });
|
||
|
||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||
...init,
|
||
headers,
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) this.handleUnauthorized();
|
||
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
||
const logLevel = res.status === 404 ? "warn" : "error";
|
||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||
throw new ApiError(message, res.status, res.statusText, body);
|
||
}
|
||
|
||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||
return res;
|
||
}
|
||
|
||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await this.fetchRaw(path, {
|
||
...init,
|
||
extraHeaders: { "Content-Type": "application/json" },
|
||
});
|
||
// Handle 204 No Content
|
||
if (res.status === 204) {
|
||
return undefined as T;
|
||
}
|
||
return res.json() as Promise<T>;
|
||
}
|
||
|
||
// Auth
|
||
async sendCode(email: string): Promise<void> {
|
||
await this.fetch("/auth/send-code", {
|
||
method: "POST",
|
||
body: JSON.stringify({ email }),
|
||
});
|
||
}
|
||
|
||
async verifyCode(email: string, code: string): Promise<LoginResponse> {
|
||
return this.fetch("/auth/verify-code", {
|
||
method: "POST",
|
||
body: JSON.stringify({ email, code }),
|
||
});
|
||
}
|
||
|
||
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
|
||
return this.fetch("/auth/google", {
|
||
method: "POST",
|
||
body: JSON.stringify({ code, redirect_uri: redirectUri }),
|
||
});
|
||
}
|
||
|
||
async logout(): Promise<void> {
|
||
await this.fetch("/auth/logout", { method: "POST" });
|
||
}
|
||
|
||
async issueCliToken(): Promise<{ token: string }> {
|
||
return this.fetch("/api/cli-token", { method: "POST" });
|
||
}
|
||
|
||
async getMe(): Promise<User> {
|
||
return this.fetch("/api/me");
|
||
}
|
||
|
||
async markOnboardingComplete(payload?: {
|
||
completion_path?: OnboardingCompletionPath;
|
||
workspace_id?: string;
|
||
}): Promise<User> {
|
||
return this.fetch("/api/me/onboarding/complete", {
|
||
method: "POST",
|
||
body: payload ? JSON.stringify(payload) : undefined,
|
||
});
|
||
}
|
||
|
||
async joinCloudWaitlist(payload: {
|
||
email: string;
|
||
reason?: string;
|
||
}): Promise<User> {
|
||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
async patchOnboarding(payload: {
|
||
questionnaire?: Record<string, unknown>;
|
||
}): Promise<User> {
|
||
return this.fetch("/api/me/onboarding", {
|
||
method: "PATCH",
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Imports the Getting Started project + optional welcome issue + sub-issues
|
||
* in a single server-side transaction. Gated by an atomic
|
||
* starter_content_state: NULL → 'imported' claim — a second call returns
|
||
* 409 (already decided) and creates nothing new.
|
||
*
|
||
* The content templates live in TypeScript (see
|
||
* @multica/views/onboarding/utils/starter-content-templates) and are
|
||
* rendered from the user's questionnaire answers before being sent.
|
||
*/
|
||
async importStarterContent(
|
||
payload: ImportStarterContentPayload,
|
||
): Promise<ImportStarterContentResponse> {
|
||
return this.fetch("/api/me/starter-content/import", {
|
||
method: "POST",
|
||
body: JSON.stringify(payload),
|
||
});
|
||
}
|
||
|
||
async dismissStarterContent(payload?: {
|
||
workspace_id?: string;
|
||
}): Promise<User> {
|
||
return this.fetch("/api/me/starter-content/dismiss", {
|
||
method: "POST",
|
||
body: payload ? JSON.stringify(payload) : undefined,
|
||
});
|
||
}
|
||
|
||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||
return this.fetch("/api/me", {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Issues
|
||
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.limit) search.set("limit", String(params.limit));
|
||
if (params?.offset) search.set("offset", String(params.offset));
|
||
if (params?.workspace_id) search.set("workspace_id", params.workspace_id);
|
||
if (params?.status) search.set("status", params.status);
|
||
if (params?.priority) search.set("priority", params.priority);
|
||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||
if (params?.project_id) search.set("project_id", params.project_id);
|
||
if (params?.open_only) search.set("open_only", "true");
|
||
const path = `/api/issues?${search}`;
|
||
const raw = await this.fetch<unknown>(path);
|
||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||
endpoint: "GET /api/issues",
|
||
});
|
||
}
|
||
|
||
async 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.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 deleteRuntime(runtimeId: string): Promise<void> {
|
||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async updateRuntime(
|
||
runtimeId: string,
|
||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||
): Promise<AgentRuntime> {
|
||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(patch),
|
||
});
|
||
}
|
||
|
||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||
const search = new URLSearchParams();
|
||
if (params?.days) search.set("days", String(params.days));
|
||
return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`);
|
||
}
|
||
|
||
async getRuntimeTaskActivity(runtimeId: string): Promise<RuntimeHourlyActivity[]> {
|
||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||
}
|
||
|
||
async getRuntimeUsageByAgent(
|
||
runtimeId: string,
|
||
params?: { days?: number },
|
||
): Promise<RuntimeUsageByAgent[]> {
|
||
const search = new URLSearchParams();
|
||
if (params?.days) search.set("days", String(params.days));
|
||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-agent?${search}`);
|
||
}
|
||
|
||
async getRuntimeUsageByHour(
|
||
runtimeId: string,
|
||
params?: { days?: number },
|
||
): Promise<RuntimeUsageByHour[]> {
|
||
const search = new URLSearchParams();
|
||
if (params?.days) search.set("days", String(params.days));
|
||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
|
||
// Each accepts an optional `project_id` to narrow the scope to one project.
|
||
// Cost is computed client-side from the model pricing table (same contract
|
||
// as the per-runtime endpoints above).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async getDashboardUsageDaily(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardUsageDaily[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||
return parseWithFallback<DashboardUsageDaily[]>(
|
||
raw,
|
||
DashboardUsageDailyListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/usage/daily" },
|
||
);
|
||
}
|
||
|
||
async getDashboardUsageByAgent(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardUsageByAgent[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||
raw,
|
||
DashboardUsageByAgentListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/usage/by-agent" },
|
||
);
|
||
}
|
||
|
||
async getDashboardAgentRunTime(
|
||
params: { days?: number; project_id?: string | null },
|
||
): Promise<DashboardAgentRunTime[]> {
|
||
const search = new URLSearchParams();
|
||
if (params.days) search.set("days", String(params.days));
|
||
if (params.project_id) search.set("project_id", params.project_id);
|
||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||
raw,
|
||
DashboardAgentRunTimeListSchema,
|
||
[],
|
||
{ endpoint: "GET /api/dashboard/agent-runtime" },
|
||
);
|
||
}
|
||
|
||
async getDashboardRunTimeDaily(
|
||
params: { days?: number; project_id?: string | null },
|
||
): 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);
|
||
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): Promise<AgentTask> {
|
||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
// Inbox
|
||
async listInbox(): Promise<InboxItem[]> {
|
||
return this.fetch("/api/inbox");
|
||
}
|
||
|
||
async markInboxRead(id: string): Promise<InboxItem> {
|
||
return this.fetch(`/api/inbox/${id}/read`, { method: "POST" });
|
||
}
|
||
|
||
async archiveInbox(id: string): Promise<InboxItem> {
|
||
return this.fetch(`/api/inbox/${id}/archive`, { method: "POST" });
|
||
}
|
||
|
||
async getUnreadInboxCount(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/unread-count");
|
||
}
|
||
|
||
async markAllInboxRead(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||
}
|
||
|
||
async archiveAllInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
||
}
|
||
|
||
async archiveAllReadInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
||
}
|
||
|
||
async archiveCompletedInbox(): Promise<{ count: number }> {
|
||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||
}
|
||
|
||
// Notification preferences
|
||
async getNotificationPreferences(): Promise<NotificationPreferenceResponse> {
|
||
return this.fetch("/api/notification-preferences");
|
||
}
|
||
|
||
async updateNotificationPreferences(preferences: NotificationPreferences): Promise<NotificationPreferenceResponse> {
|
||
return this.fetch("/api/notification-preferences", {
|
||
method: "PUT",
|
||
body: JSON.stringify({ preferences }),
|
||
});
|
||
}
|
||
|
||
// App Config
|
||
async getConfig(): Promise<{
|
||
cdn_domain: string;
|
||
allow_signup: boolean;
|
||
google_client_id?: string;
|
||
posthog_key?: string;
|
||
posthog_host?: string;
|
||
analytics_environment?: string;
|
||
}> {
|
||
return this.fetch("/api/config");
|
||
}
|
||
|
||
// Workspaces
|
||
async listWorkspaces(): Promise<Workspace[]> {
|
||
return this.fetch("/api/workspaces");
|
||
}
|
||
|
||
async getWorkspace(id: string): Promise<Workspace> {
|
||
return this.fetch(`/api/workspaces/${id}`);
|
||
}
|
||
|
||
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
|
||
return this.fetch("/api/workspaces", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
||
return this.fetch(`/api/workspaces/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Members
|
||
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/members`);
|
||
}
|
||
|
||
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateMember(workspaceId: string, memberId: string, data: UpdateMemberRequest): Promise<MemberWithUser> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteMember(workspaceId: string, memberId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/members/${memberId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
async leaveWorkspace(workspaceId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/leave`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
// Invitations
|
||
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
|
||
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
|
||
}
|
||
|
||
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
async listMyInvitations(): Promise<Invitation[]> {
|
||
return this.fetch("/api/invitations");
|
||
}
|
||
|
||
async getInvitation(invitationId: string): Promise<Invitation> {
|
||
return this.fetch(`/api/invitations/${invitationId}`);
|
||
}
|
||
|
||
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
|
||
return this.fetch(`/api/invitations/${invitationId}/accept`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
async declineInvitation(invitationId: string): Promise<void> {
|
||
await this.fetch(`/api/invitations/${invitationId}/decline`, {
|
||
method: "POST",
|
||
});
|
||
}
|
||
|
||
async deleteWorkspace(workspaceId: string): Promise<void> {
|
||
await this.fetch(`/api/workspaces/${workspaceId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Skills
|
||
async listSkills(): Promise<SkillSummary[]> {
|
||
return this.fetch("/api/skills");
|
||
}
|
||
|
||
async getSkill(id: string): Promise<Skill> {
|
||
return this.fetch(`/api/skills/${id}`);
|
||
}
|
||
|
||
async createSkill(data: CreateSkillRequest): Promise<Skill> {
|
||
return this.fetch("/api/skills", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateSkill(id: string, data: UpdateSkillRequest): Promise<Skill> {
|
||
return this.fetch(`/api/skills/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteSkill(id: string): Promise<void> {
|
||
await this.fetch(`/api/skills/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async importSkill(data: { url: string }): Promise<Skill> {
|
||
return this.fetch("/api/skills/import", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
|
||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||
}
|
||
|
||
async setAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
||
await this.fetch(`/api/agents/${agentId}/skills`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Personal Access Tokens
|
||
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
||
return this.fetch("/api/tokens");
|
||
}
|
||
|
||
async createPersonalAccessToken(data: CreatePersonalAccessTokenRequest): Promise<CreatePersonalAccessTokenResponse> {
|
||
return this.fetch("/api/tokens", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async revokePersonalAccessToken(id: string): Promise<void> {
|
||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// File Upload & Attachments
|
||
async uploadFile(
|
||
file: File,
|
||
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
|
||
): Promise<Attachment> {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
||
|
||
const rid = createRequestId();
|
||
const start = Date.now();
|
||
this.logger.info("→ POST /api/upload-file", { rid });
|
||
|
||
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
||
method: "POST",
|
||
headers: this.authHeaders(),
|
||
body: formData,
|
||
credentials: "include",
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 401) this.handleUnauthorized();
|
||
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
||
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||
throw new Error(message);
|
||
}
|
||
|
||
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||
const raw = (await res.json()) as unknown;
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "POST /api/upload-file",
|
||
});
|
||
}
|
||
|
||
// Chat Sessions
|
||
async listChatSessions(params?: { status?: string }): Promise<ChatSession[]> {
|
||
const query = params?.status ? `?status=${params.status}` : "";
|
||
return this.fetch(`/api/chat/sessions${query}`);
|
||
}
|
||
|
||
async getChatSession(id: string): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}`);
|
||
}
|
||
|
||
async createChatSession(data: { agent_id: string; title?: string }): Promise<ChatSession> {
|
||
return this.fetch("/api/chat/sessions", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteChatSession(id: string): Promise<void> {
|
||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||
}
|
||
|
||
async sendChatMessage(
|
||
sessionId: string,
|
||
content: string,
|
||
attachmentIds?: string[],
|
||
): Promise<SendChatMessageResponse> {
|
||
const body: { content: string; attachment_ids?: string[] } = { content };
|
||
if (attachmentIds && attachmentIds.length > 0) {
|
||
body.attachment_ids = attachmentIds;
|
||
}
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||
}
|
||
|
||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||
return this.fetch(`/api/chat/pending-tasks`);
|
||
}
|
||
|
||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||
}
|
||
|
||
async cancelTaskById(taskId: string): Promise<void> {
|
||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||
}
|
||
|
||
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||
}
|
||
|
||
// Fetches a fresh attachment metadata record. The server re-signs
|
||
// `download_url` on every call (30 min expiry), so the click-time
|
||
// download flow uses this endpoint to avoid handing the user a stale
|
||
// signed URL cached in TanStack Query.
|
||
async getAttachment(id: string): Promise<Attachment> {
|
||
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||
endpoint: "GET /api/attachments/{id}",
|
||
});
|
||
}
|
||
|
||
async deleteAttachment(id: string): Promise<void> {
|
||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// Fetches the raw bytes of a text-previewable attachment.
|
||
//
|
||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||
// of which would otherwise prevent the renderer from getting the body.
|
||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||
// the preview dispatcher can choose between markdown / html / plain code.
|
||
//
|
||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||
// the modal can render specific fallbacks instead of generic failure.
|
||
async getAttachmentTextContent(
|
||
id: string,
|
||
): Promise<{ text: string; originalContentType: string }> {
|
||
let res: Response;
|
||
try {
|
||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||
} catch (err) {
|
||
if (err instanceof ApiError) {
|
||
if (err.status === 413) throw new PreviewTooLargeError();
|
||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||
}
|
||
throw err;
|
||
}
|
||
return {
|
||
text: await res.text(),
|
||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||
};
|
||
}
|
||
|
||
// Projects
|
||
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
||
const search = new URLSearchParams();
|
||
if (params?.status) search.set("status", params.status);
|
||
return this.fetch(`/api/projects?${search}`);
|
||
}
|
||
|
||
async getProject(id: string): Promise<Project> {
|
||
return this.fetch(`/api/projects/${id}`);
|
||
}
|
||
|
||
async createProject(data: CreateProjectRequest): Promise<Project> {
|
||
return this.fetch("/api/projects", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||
return this.fetch(`/api/projects/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteProject(id: string): Promise<void> {
|
||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
// Project resources
|
||
async listProjectResources(
|
||
projectId: string,
|
||
): Promise<ListProjectResourcesResponse> {
|
||
return this.fetch(`/api/projects/${projectId}/resources`);
|
||
}
|
||
|
||
async createProjectResource(
|
||
projectId: string,
|
||
data: CreateProjectResourceRequest,
|
||
): Promise<ProjectResource> {
|
||
return this.fetch(`/api/projects/${projectId}/resources`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteProjectResource(
|
||
projectId: string,
|
||
resourceId: string,
|
||
): Promise<void> {
|
||
await this.fetch(`/api/projects/${projectId}/resources/${resourceId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Labels
|
||
async listLabels(): Promise<ListLabelsResponse> {
|
||
return this.fetch(`/api/labels`);
|
||
}
|
||
|
||
async getLabel(id: string): Promise<Label> {
|
||
return this.fetch(`/api/labels/${id}`);
|
||
}
|
||
|
||
async createLabel(data: CreateLabelRequest): Promise<Label> {
|
||
return this.fetch(`/api/labels`, {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
|
||
return this.fetch(`/api/labels/${id}`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteLabel(id: string): Promise<void> {
|
||
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
|
||
}
|
||
|
||
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels`);
|
||
}
|
||
|
||
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ label_id: labelId }),
|
||
});
|
||
}
|
||
|
||
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
|
||
method: "DELETE",
|
||
});
|
||
}
|
||
|
||
// Pins
|
||
async listPins(): Promise<PinnedItem[]> {
|
||
return this.fetch("/api/pins");
|
||
}
|
||
|
||
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
||
return this.fetch("/api/pins", {
|
||
method: "POST",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
||
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
||
}
|
||
|
||
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
||
await this.fetch("/api/pins/reorder", {
|
||
method: "PUT",
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Squads
|
||
async listSquads(): Promise<Squad[]> {
|
||
return this.fetch(`/api/squads`);
|
||
}
|
||
|
||
async getSquad(id: string): Promise<Squad> {
|
||
return this.fetch(`/api/squads/${id}`);
|
||
}
|
||
|
||
async createSquad(data: { name: string; description?: string; leader_id: string; 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) });
|
||
}
|
||
|
||
// 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" },
|
||
);
|
||
}
|
||
|
||
// 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`);
|
||
}
|
||
}
|