mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 21:59:30 +02:00
* feat(labels): add issue label CRUD + attach/detach handlers (#1191) The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql but never wired to any code path. This commit ships the backend for #1191: - Migration 048: adds created_at/updated_at timestamps + workspace-scoped case-insensitive unique index on label names - sqlc queries for label CRUD + issue<->label attach/detach + batch list (ListLabelsByIssueIDs for board/list views) - HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach - Protocol events: label:{created,updated,deleted} + issue_labels:changed - Handler tests covering CRUD, duplicate-name conflict, invalid-color, attach/detach idempotency, and cross-workspace isolation * feat(cli): add label and issue label subcommands (#1191) - multica label {list,get,create,update,delete} - multica issue label {list,add,remove} Both follow existing CLI conventions (JSON/table output, flag shapes) and exercise the /api/labels endpoints shipped in the previous commit. * feat(web): add labels UI — picker with inline create + management dialog (#1191) Exposes the backend label feature to users via the existing issue-detail sidebar. - `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest, plus response envelopes - `@multica/core/api/client` — 8 methods for label CRUD and issue↔label attach/detach - `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with optimistic updates (matches the project/ module layout) - WS event type literals extended for label:{created,updated,deleted} and issue_labels:changed - `views/labels/label-chip.tsx` — colored pill; uses relative luminance (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on both pastel and saturated backgrounds - `views/issues/components/pickers/label-picker.tsx` - Multi-select combobox in the issue sidebar - When 0 labels: "Add label" trigger - When 1+ labels: the chips themselves are the trigger; × on each chip detaches without opening the picker - Inline create: typing a new name + Enter creates with a hash-derived color and attaches in one motion (matches Linear/GitHub) - "Manage labels…" footer opens a dialog containing the full workspace panel — users never leave the issue context to rename/recolor/delete - `views/issues/components/labels-panel.tsx` — workspace labels manager. Single-row create form (color swatch + name + Add button). Each label row supports inline rename + recolor + delete (with confirm dialog). Color input uses the browser's native picker for full-gamut access — no preset palette clutter. - `PropRow label="Labels"` added to the issue-detail sidebar below Project Labels are issue metadata everyone uses — not admin configuration. Putting them in Settings next to destructive workspace actions misframed them; adding a top-level nav entry or a sibling tab to the Issues page added surface area that wasn't earning its keep for a feature users touch occasionally. Keeping management in a dialog launched from the picker itself keeps users in their issue context and matches how GitHub handles label editing from the label selector.
1111 lines
34 KiB
TypeScript
1111 lines
34 KiB
TypeScript
import type {
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
UpdateMeRequest,
|
|
CreateMemberRequest,
|
|
UpdateMemberRequest,
|
|
ListIssuesParams,
|
|
Agent,
|
|
CreateAgentRequest,
|
|
UpdateAgentRequest,
|
|
AgentTask,
|
|
AgentRuntime,
|
|
InboxItem,
|
|
IssueSubscriber,
|
|
Comment,
|
|
Reaction,
|
|
IssueReaction,
|
|
Workspace,
|
|
WorkspaceRepo,
|
|
MemberWithUser,
|
|
User,
|
|
Skill,
|
|
CreateSkillRequest,
|
|
UpdateSkillRequest,
|
|
SetAgentSkillsRequest,
|
|
PersonalAccessToken,
|
|
CreatePersonalAccessTokenRequest,
|
|
CreatePersonalAccessTokenResponse,
|
|
RuntimeUsage,
|
|
IssueUsageSummary,
|
|
RuntimeHourlyActivity,
|
|
RuntimeUpdate,
|
|
RuntimeModelListRequest,
|
|
RuntimeLocalSkillListRequest,
|
|
CreateRuntimeLocalSkillImportRequest,
|
|
RuntimeLocalSkillImportRequest,
|
|
TimelineEntry,
|
|
AssigneeFrequencyEntry,
|
|
TaskMessagePayload,
|
|
Attachment,
|
|
ChatSession,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
PendingChatTasksResponse,
|
|
SendChatMessageResponse,
|
|
Project,
|
|
CreateProjectRequest,
|
|
UpdateProjectRequest,
|
|
ListProjectsResponse,
|
|
Label,
|
|
CreateLabelRequest,
|
|
UpdateLabelRequest,
|
|
ListLabelsResponse,
|
|
IssueLabelsResponse,
|
|
PinnedItem,
|
|
CreatePinRequest,
|
|
PinnedItemType,
|
|
ReorderPinsRequest,
|
|
Invitation,
|
|
Autopilot,
|
|
AutopilotTrigger,
|
|
AutopilotRun,
|
|
CreateAutopilotRequest,
|
|
UpdateAutopilotRequest,
|
|
CreateAutopilotTriggerRequest,
|
|
UpdateAutopilotTriggerRequest,
|
|
ListAutopilotsResponse,
|
|
GetAutopilotResponse,
|
|
ListAutopilotRunsResponse,
|
|
} from "../types";
|
|
import type { OnboardingCompletionPath } from "../onboarding/types";
|
|
import { type Logger, noopLogger } from "../logger";
|
|
import { createRequestId } from "../utils";
|
|
import { getCurrentSlug } from "../platform/workspace-storage";
|
|
|
|
/** 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;
|
|
|
|
constructor(message: string, status: number, statusText: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.statusText = statusText;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const rid = createRequestId();
|
|
const start = Date.now();
|
|
const method = init?.method ?? "GET";
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
"X-Request-ID": rid,
|
|
...this.authHeaders(),
|
|
...((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 = await this.parseErrorMessage(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);
|
|
}
|
|
|
|
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
|
|
|
// 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;
|
|
}): 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");
|
|
return this.fetch(`/api/issues?${search}`);
|
|
}
|
|
|
|
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 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[] }> {
|
|
return this.fetch(`/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[]> {
|
|
return this.fetch(`/api/issues/${issueId}/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[]> {
|
|
return this.fetch(`/api/issues/${issueId}/timeline`);
|
|
}
|
|
|
|
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
|
return this.fetch("/api/assignee-frequency");
|
|
}
|
|
|
|
async updateComment(commentId: string, content: string): Promise<Comment> {
|
|
return this.fetch(`/api/comments/${commentId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
async deleteComment(commentId: string): Promise<void> {
|
|
await this.fetch(`/api/comments/${commentId}`, { 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[]> {
|
|
return this.fetch(`/api/issues/${issueId}/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 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" });
|
|
}
|
|
|
|
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 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 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`);
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
|
|
// 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" });
|
|
}
|
|
|
|
// App Config
|
|
async getConfig(): Promise<{
|
|
cdn_domain: string;
|
|
allow_signup: boolean;
|
|
google_client_id?: string;
|
|
posthog_key?: string;
|
|
posthog_host?: 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<Skill[]> {
|
|
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<Skill[]> {
|
|
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 }): 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);
|
|
|
|
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` });
|
|
return res.json() as Promise<Attachment>;
|
|
}
|
|
|
|
// 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 archiveChatSession(id: string): Promise<void> {
|
|
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
|
}
|
|
|
|
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
|
|
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
async deleteAttachment(id: string): Promise<void> {
|
|
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
|
}
|
|
|
|
// 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" });
|
|
}
|
|
|
|
// 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),
|
|
});
|
|
}
|
|
|
|
// 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}`);
|
|
}
|
|
|
|
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" });
|
|
}
|
|
}
|