mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* feat(server): add quick-create issue async task path Adds POST /api/issues/quick-create which validates the picked agent's reachability up front (not archived, has runtime, runtime online) then queues an issue-less agent task whose context JSONB carries the user's natural-language prompt + requester + workspace. Daemon claim resolves the workspace from the context, and the prompt builder switches to a quick-create template instructing the agent to translate the prompt into a single multica issue create call. Task completion writes a success inbox item to the requester pointing at the newly-created issue (located by querying the agent's most recent issue in the workspace since task start, so we don't depend on agent stdout shape). Failures write an action_required inbox item carrying the original prompt + agent id so the frontend can offer "Edit as advanced form" without losing input. * feat(views): quick-create issue modal + inbox failure CTA Adds a streamlined create-issue UI bound to the c shortcut: pick an agent, type one line, submit. The modal closes immediately and the agent translates the prompt into a multica issue create call in the background. Shift+c keeps the legacy advanced form for users who want every field. The "Advanced" button inside the new modal seeds the shared issue-draft store with the prompt + picked agent so switching mid-flow doesn't lose input. Last-used agent persists per (user, workspace) via a workspace-aware zustand store so frequent users skip the picker on every open. Inbox renders quick_create_done items with a status pin to the new issue and quick_create_failed items with an "Edit as advanced form" CTA that re-seeds the legacy modal with the original prompt. ApiError now carries the parsed JSON body so the modal can branch on the structured agent_unavailable code without parsing the error message. * fix(quick-create): execenv injection, claim race, private-agent permission Addresses GPT-Boy review on #1786: 1. execenv was rendering the assignment-task issue_context.md / runtime workflow even for quick-create, telling the agent to call `multica issue get/status/comment add` against an empty IssueID. Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create branch in renderIssueContext + the runtime_config workflow that instructs the agent to run a single `multica issue create` and exit, with explicit "do NOT call issue get/status/comment add" guards. 2. ClaimAgentTask serialized only on issue_id / chat_session_id, so concurrent quick-creates on the same agent (both NULL on those columns) ran in parallel — making the success-inbox lookup race over "most recent issue by this agent". Adds a third OR clause that treats "all four FKs NULL" as a serialization key for the same agent, so quick-create tasks on a given agent run one at a time. 3. QuickCreateIssue handler bypassed the private-agent ownership rule that validateAssigneePair enforces elsewhere — a user could POST a private agent_id they didn't own and trigger it. Now routes the picked agent through validateAssigneePair before the runtime liveness check. 4. Clarifies the quick-create-store namespacing comment to match the actual workspace-aware StateStorage convention used by the other issue stores (per-user is browser-profile-local). * fix(quick-create): branch Output section + deterministic origin lookup Addresses GPT-Boy's second-pass review on #1786: 1. The runtime_config.go Output section forced "Final results MUST be delivered via multica issue comment add" for every non-autopilot task — quick-create still got this conflicting instruction even though there's no issue to comment on. Switched the Output block to a three-way switch so quick-create gets a tailored "stdout is captured automatically; do NOT call comment add" branch matching the autopilot variant. 2. Completion lookup was "most recent issue created by this agent since task.started_at", which races against concurrent issue creates by the same agent (assignment task running alongside quick-create when max_concurrent_tasks > 1). Replaced with a deterministic origin link: - Migration 060 extends issue.origin_type CHECK to allow 'quick_create'. - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a quick-create task. - multica issue create CLI reads the env var and stamps the new issue with origin_type=quick_create + origin_id=<task_id>. - Server CreateIssue handler accepts (origin_type, origin_id) from trusted callers (only "quick_create" is allowed; the pair is rejected unless both fields are provided together). - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on (workspace_id, "quick_create", task.ID) — no more time-window racing against parallel agent activity. The old GetRecentIssueByCreatorSince query is removed.
1188 lines
38 KiB
TypeScript
1188 lines
38 KiB
TypeScript
import type {
|
|
Issue,
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesResponse,
|
|
SearchIssuesResponse,
|
|
SearchProjectsResponse,
|
|
UpdateMeRequest,
|
|
CreateMemberRequest,
|
|
UpdateMemberRequest,
|
|
ListIssuesParams,
|
|
Agent,
|
|
CreateAgentRequest,
|
|
UpdateAgentRequest,
|
|
AgentTask,
|
|
AgentActivityBucket,
|
|
AgentRunCount,
|
|
AgentRuntime,
|
|
InboxItem,
|
|
IssueSubscriber,
|
|
Comment,
|
|
Reaction,
|
|
IssueReaction,
|
|
Workspace,
|
|
WorkspaceRepo,
|
|
MemberWithUser,
|
|
User,
|
|
Skill,
|
|
CreateSkillRequest,
|
|
UpdateSkillRequest,
|
|
SetAgentSkillsRequest,
|
|
PersonalAccessToken,
|
|
CreatePersonalAccessTokenRequest,
|
|
CreatePersonalAccessTokenResponse,
|
|
RuntimeUsage,
|
|
IssueUsageSummary,
|
|
RuntimeHourlyActivity,
|
|
RuntimeUsageByAgent,
|
|
RuntimeUsageByHour,
|
|
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;
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
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, 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` });
|
|
|
|
// 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 quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
|
return this.fetch("/api/issues/quick-create", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async createFeedback(data: {
|
|
message: string;
|
|
url?: string;
|
|
workspace_id?: string;
|
|
}): Promise<{ id: string; created_at: string }> {
|
|
return this.fetch("/api/feedback", {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateIssue(id: string, data: UpdateIssueRequest): Promise<Issue> {
|
|
return this.fetch(`/api/issues/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
|
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" });
|
|
}
|
|
|
|
// 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 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}`);
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
|
|
// 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" });
|
|
}
|
|
}
|