Files
fusion/packages/dashboard/app/api.ts
gsxdsm 5a30aa5206 feat(FN-773): add dedicated ScriptRunDialog for saved script output
- Add ScriptRunDialog component with xterm.js terminal emulation and real-time streaming
- Align saved-script launches with backend contract (POST /api/scripts/:id/run)
- Wire dialog into App.tsx with open/close state management
- Add comprehensive styles for dialog overlay, terminal area, and action buttons
- Add unit tests for ScriptRunDialog and App integration
- Document ScriptRunDialog usage in dashboard README
2026-04-03 10:31:30 -07:00

2370 lines
74 KiB
TypeScript

import type {
Task,
TaskDetail,
TaskAttachment,
TaskComment,
TaskCreateInput,
AgentLogEntry,
Column,
MergeResult,
Settings,
GlobalSettings,
ProjectSettings,
BatchStatusResult,
BatchStatusResponse,
BatchStatusEntry,
ActivityLogEntry,
ActivityEventType,
WorkflowStep,
WorkflowStepInput,
WorkflowStepResult,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
function looksLikeHtml(body: string): boolean {
const trimmed = body.trim();
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
}
function buildApiUrl(path: string): string {
return `/api${path}`;
}
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const url = buildApiUrl(path);
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
...opts,
});
const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text();
const isJson = contentType.includes("application/json");
const isHtml = contentType.includes("text/html") || looksLikeHtml(bodyText);
if (isHtml) {
throw new Error(
`API returned HTML instead of JSON for ${url}. ` +
`The endpoint may not be properly configured. (${res.status} ${res.statusText})`
);
}
if (!isJson) {
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText;
throw new Error(
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` +
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}`
);
}
let data: unknown;
try {
data = bodyText ? JSON.parse(bodyText) : null;
} catch {
throw new Error(
`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`
);
}
if (!res.ok) {
throw new Error((data as { error?: string } | null)?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`);
}
return data as T;
}
export function fetchTasks(limit?: number, offset?: number, projectId?: string): Promise<Task[]> {
const search = new URLSearchParams();
if (limit !== undefined) search.set("limit", String(limit));
if (offset !== undefined) search.set("offset", String(offset));
if (projectId) search.set("projectId", projectId);
const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<Task[]>(`/tasks${suffix}`);
}
export async function fetchTaskDetail(id: string, projectId?: string): Promise<TaskDetail> {
const maxAttempts = 2; // 1 initial + 1 retry
const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
if (res.ok) return data as TaskDetail;
if (attempt === maxAttempts) {
throw new Error((data as { error?: string }).error || "Request failed");
}
}
// unreachable
throw new Error("Request failed");
}
export function createTask(input: TaskCreateInput, projectId?: string): Promise<Task> {
const {
title,
description,
column,
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
} = input;
return api<Task>(withProjectId("/tasks", projectId), {
method: "POST",
body: JSON.stringify({
title,
description,
column,
dependencies,
breakIntoSubtasks,
enabledWorkflowSteps,
modelPresetId,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
}),
});
}
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[]; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null }, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/**
* Batch update AI model configuration for multiple tasks.
* @param taskIds - Array of task IDs to update
* @param modelProvider - Executor model provider (optional, null to clear)
* @param modelId - Executor model ID (optional, null to clear)
* @param validatorModelProvider - Validator model provider (optional, null to clear)
* @param validatorModelId - Validator model ID (optional, null to clear)
* @returns Promise with updated tasks and count
*/
export function batchUpdateTaskModels(
taskIds: string[],
modelProvider?: string | null,
modelId?: string | null,
validatorModelProvider?: string | null,
validatorModelId?: string | null,
projectId?: string,
): Promise<{ updated: Task[]; count: number }> {
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
method: "POST",
body: JSON.stringify({
taskIds,
modelProvider,
modelId,
validatorModelProvider,
validatorModelId,
}),
});
}
export function moveTask(id: string, column: Column, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/move`, projectId), {
method: "POST",
body: JSON.stringify({ column }),
});
}
export function deleteTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}`, projectId), { method: "DELETE" });
}
export function mergeTask(id: string, projectId?: string): Promise<MergeResult> {
return api<MergeResult>(withProjectId(`/tasks/${id}/merge`, projectId), { method: "POST" });
}
export function retryTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" });
}
export function duplicateTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/duplicate`, projectId), { method: "POST" });
}
export function pauseTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/pause`, projectId), { method: "POST" });
}
export function unpauseTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/unpause`, projectId), { method: "POST" });
}
export function archiveTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/archive`, projectId), { method: "POST" });
}
export function unarchiveTask(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/unarchive`, projectId), { method: "POST" });
}
export function archiveAllDone(projectId?: string): Promise<Task[]> {
return api<{ archived: Task[] }>(withProjectId("/tasks/archive-all-done", projectId), { method: "POST" }).then(
(response) => response.archived
);
}
export function approvePlan(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/approve-plan`, projectId), { method: "POST" });
}
export function rejectPlan(id: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/reject-plan`, projectId), { method: "POST" });
}
export function fetchConfig(projectId?: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(withProjectId("/config", projectId));
}
export function fetchSettings(projectId?: string): Promise<Settings> {
return api<Settings>(withProjectId("/settings", projectId));
}
export function updateSettings(settings: Partial<Settings>, projectId?: string): Promise<Settings> {
return api<Settings>(withProjectId("/settings", projectId), {
method: "PUT",
body: JSON.stringify(settings),
});
}
/** Fetch global (user-level) settings from ~/.pi/fusion/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global");
}
/** Update global (user-level) settings. These persist across all kb projects. */
export function updateGlobalSettings(settings: Partial<GlobalSettings>): Promise<Settings> {
return api<Settings>("/settings/global", {
method: "PUT",
body: JSON.stringify(settings),
});
}
/** Fetch settings separated by scope: { global, project } */
export function fetchSettingsByScope(projectId?: string): Promise<{ global: GlobalSettings; project: Partial<ProjectSettings> }> {
return api<{ global: GlobalSettings; project: Partial<ProjectSettings> }>(withProjectId("/settings/scopes", projectId));
}
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string }, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
method: "POST",
body: config ? JSON.stringify(config) : undefined,
});
}
export async function uploadAttachment(id: string, file: File, projectId?: string): Promise<TaskAttachment> {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(buildApiUrl(withProjectId(`/tasks/${id}/attachments`, projectId)), {
method: "POST",
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || "Upload failed");
return data as TaskAttachment;
}
export async function deleteAttachment(id: string, filename: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" });
}
export function fetchAgentLogs(taskId: string, projectId?: string): Promise<AgentLogEntry[]> {
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs`, projectId));
}
export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId));
}
export function fetchTaskComments(id: string, projectId?: string): Promise<TaskComment[]> {
return api<TaskComment[]>(withProjectId(`/tasks/${id}/comments`, projectId));
}
export function addTaskComment(id: string, text: string, author?: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/comments`, projectId), {
method: "POST",
body: JSON.stringify({ text, author }),
});
}
export function updateTaskComment(id: string, commentId: string, text: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), {
method: "PATCH",
body: JSON.stringify({ text }),
});
}
export function deleteTaskComment(id: string, commentId: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/comments/${commentId}`, projectId), {
method: "DELETE",
});
}
export function addSteeringComment(id: string, text: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/steer`, projectId), {
method: "POST",
body: JSON.stringify({ text }),
});
}
export function requestSpecRevision(id: string, feedback: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/spec/revise`, projectId), {
method: "POST",
body: JSON.stringify({ feedback }),
});
}
export function refineTask(id: string, feedback: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/refine`, projectId), {
method: "POST",
body: JSON.stringify({ feedback }),
});
}
// --- Models API ---
/** Available AI model info returned by the models endpoint */
export interface ModelInfo {
provider: string;
id: string;
name: string;
reasoning: boolean;
contextWindow: number;
}
/** Response from the models endpoint */
export interface ModelsResponse {
models: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
}
/** Fetch available AI models from the model registry along with favoriteProviders */
export function fetchModels(): Promise<ModelsResponse> {
return api<ModelsResponse>("/models");
}
// --- Usage API ---
/** Pace information for weekly usage windows */
export interface UsagePace {
status: "ahead" | "on-track" | "behind";
percentElapsed: number; // 0-100, how much of the window time has passed
message: string; // e.g., "Using 15% over your limit pace"
}
/** Usage window for a provider (e.g., "Session (5h)", "Weekly") */
export interface UsageWindow {
label: string;
percentUsed: number; // 0-100
percentLeft: number; // 0-100
resetText: string | null; // e.g., "resets in 2h"
resetMs?: number; // ms until reset
resetAt?: string; // ISO 8601 timestamp of when the window resets (machine-readable)
windowDurationMs?: number; // total window length
pace?: UsagePace; // pace indicator for weekly windows
}
/** Provider usage data */
export interface ProviderUsage {
name: string;
icon: string; // emoji
status: "ok" | "error" | "no-auth";
error?: string;
plan?: string | null;
email?: string | null;
windows: UsageWindow[];
}
/** Fetch usage data from all configured AI providers */
export function fetchUsageData(): Promise<{ providers: ProviderUsage[] }> {
return api<{ providers: ProviderUsage[] }>("/usage");
}
// --- Auth API ---
/** OAuth provider with current authentication status */
export interface AuthProvider {
id: string;
name: string;
authenticated: boolean;
}
/** Fetch authentication status for all OAuth providers */
export function fetchAuthStatus(): Promise<{ providers: AuthProvider[] }> {
return api<{ providers: AuthProvider[] }>("/auth/status");
}
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */
export function loginProvider(provider: string): Promise<{ url: string; instructions?: string }> {
return api<{ url: string; instructions?: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ provider }),
});
}
/** Logout from a provider, removing stored credentials. */
export function logoutProvider(provider: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/logout", {
method: "POST",
body: JSON.stringify({ provider }),
});
}
// --- GitHub Import API ---
/** GitHub issue returned by the fetch endpoint */
export interface GitHubIssue {
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
}
/** Fetch open GitHub issues from a repository */
export function apiFetchGitHubIssues(
owner: string,
repo: string,
limit?: number,
labels?: string[]
): Promise<GitHubIssue[]> {
return api<GitHubIssue[]>("/github/issues/fetch", {
method: "POST",
body: JSON.stringify({ owner, repo, limit, labels }),
});
}
/** Import a specific GitHub issue as a kb task */
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number): Promise<Task> {
return api<Task>("/github/issues/import", {
method: "POST",
body: JSON.stringify({ owner, repo, issueNumber }),
});
}
/** Result of a batch import operation for a single issue */
export interface BatchImportResult {
issueNumber: number;
success: boolean;
taskId?: string;
error?: string;
skipped?: boolean;
retryAfter?: number;
}
/** Batch import multiple GitHub issues as kb tasks with throttling */
export function apiBatchImportGitHubIssues(
owner: string,
repo: string,
issueNumbers: number[],
delayMs?: number
): Promise<{ results: BatchImportResult[] }> {
return api<{ results: BatchImportResult[] }>("/github/issues/batch-import", {
method: "POST",
body: JSON.stringify({ owner, repo, issueNumbers, delayMs }),
});
}
// --- GitHub Pull Request Import API ---
/** GitHub pull request returned by the fetch endpoint */
export interface GitHubPull {
number: number;
title: string;
body: string | null;
html_url: string;
headBranch: string;
baseBranch: string;
}
/** Fetch open GitHub pull requests from a repository */
export function apiFetchGitHubPulls(
owner: string,
repo: string,
limit?: number
): Promise<GitHubPull[]> {
return api<GitHubPull[]>("/github/pulls/fetch", {
method: "POST",
body: JSON.stringify({ owner, repo, limit }),
});
}
/** Import a specific GitHub pull request as a kb review task */
export function apiImportGitHubPull(owner: string, repo: string, prNumber: number): Promise<Task> {
return api<Task>("/github/pulls/import", {
method: "POST",
body: JSON.stringify({ owner, repo, prNumber }),
});
}
// --- Git Remote Detection API ---
/** Git remote info returned by the remotes endpoint */
export interface GitRemote {
name: string;
owner: string;
repo: string;
url: string;
}
/** Fetch GitHub remotes from the current git repository */
export function fetchGitRemotes(): Promise<GitRemote[]> {
return api<GitRemote[]>("/git/remotes");
}
/** Detailed git remote info with fetch and push URLs */
export interface GitRemoteDetailed {
name: string;
fetchUrl: string;
pushUrl: string;
}
/** Fetch all git remotes with their fetch and push URLs */
export function fetchGitRemotesDetailed(): Promise<GitRemoteDetailed[]> {
return api<GitRemoteDetailed[]>("/git/remotes/detailed");
}
/** Add a new git remote */
export function addGitRemote(name: string, url: string): Promise<void> {
return api<void>("/git/remotes", {
method: "POST",
body: JSON.stringify({ name, url }),
});
}
/** Remove a git remote */
export function removeGitRemote(name: string): Promise<void> {
return api<void>(`/git/remotes/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
/** Rename a git remote */
export function renameGitRemote(name: string, newName: string): Promise<void> {
return api<void>(`/git/remotes/${encodeURIComponent(name)}`, {
method: "PATCH",
body: JSON.stringify({ newName }),
});
}
/** Update the URL for a git remote */
export function updateGitRemoteUrl(name: string, url: string): Promise<void> {
return api<void>(`/git/remotes/${encodeURIComponent(name)}/url`, {
method: "PUT",
body: JSON.stringify({ url }),
});
}
// --- PR Management API ---
/** PR info returned by PR endpoints */
export interface PrInfo {
url: string;
number: number;
status: "open" | "closed" | "merged";
title: string;
headBranch: string;
baseBranch: string;
commentCount: number;
lastCommentAt?: string;
lastCheckedAt?: string;
}
export interface PrCheckStatus {
name: string;
required: boolean;
state: string;
}
export interface PrStatusResponse {
prInfo: PrInfo;
stale: boolean;
automationStatus?: string | null;
}
export interface PrRefreshResponse {
prInfo: PrInfo;
mergeReady: boolean;
blockingReasons: string[];
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
checks: PrCheckStatus[];
automationStatus?: string | null;
}
/** Create a GitHub PR for a task */
export function createPr(
id: string,
params: { title: string; body?: string; base?: string },
projectId?: string,
): Promise<PrInfo> {
return api<PrInfo>(withProjectId(`/tasks/${id}/pr/create`, projectId), {
method: "POST",
body: JSON.stringify(params),
});
}
/** Fetch cached PR status for a task */
export function fetchPrStatus(id: string, projectId?: string): Promise<PrStatusResponse> {
return api<PrStatusResponse>(withProjectId(`/tasks/${id}/pr/status`, projectId));
}
/** Force refresh PR status from GitHub */
export function refreshPrStatus(id: string, projectId?: string): Promise<PrRefreshResponse> {
return api<PrRefreshResponse>(withProjectId(`/tasks/${id}/pr/refresh`, projectId), {
method: "POST",
});
}
// --- Issue Management API ---
/** Re-export GitHub badge-related types for convenience */
export type { IssueInfo, BatchStatusResult, BatchStatusEntry } from "@fusion/core";
/** Fetch cached issue status for a task */
export function fetchIssueStatus(id: string, projectId?: string): Promise<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }> {
return api<{ issueInfo: import("@fusion/core").IssueInfo; stale: boolean }>(withProjectId(`/tasks/${id}/issue/status`, projectId));
}
/** Force refresh issue status from GitHub */
export function refreshIssueStatus(id: string, projectId?: string): Promise<import("@fusion/core").IssueInfo> {
return api<import("@fusion/core").IssueInfo>(withProjectId(`/tasks/${id}/issue/refresh`, projectId), {
method: "POST",
});
}
/** Batch-refresh cached GitHub badge status for multiple tasks. */
export async function fetchBatchStatus(taskIds: string[], projectId?: string): Promise<BatchStatusResult> {
const response = await api<BatchStatusResponse>(withProjectId("/github/batch/status", projectId), {
method: "POST",
body: JSON.stringify({ taskIds }),
});
return response.results;
}
// --- Terminal API ---
/** Terminal exec response - returns sessionId for streaming output via SSE */
export interface TerminalExecResponse {
sessionId: string;
}
/** Terminal session status and output */
export interface TerminalSession {
id: string;
command: string;
running: boolean;
exitCode: number | null;
output: string;
startTime: string;
}
/** Terminal SSE event types */
export interface TerminalOutputEvent {
type: "stdout" | "stderr";
data: string;
}
/** Terminal exit event from SSE */
export interface TerminalExitEvent {
type: "exit";
exitCode: number;
}
/** Execute a shell command and get a session ID for streaming output */
export function execTerminalCommand(command: string): Promise<TerminalExecResponse> {
return api<TerminalExecResponse>("/terminal/exec", {
method: "POST",
body: JSON.stringify({ command }),
});
}
/** Get terminal session status and accumulated output */
export function getTerminalSession(sessionId: string): Promise<TerminalSession> {
return api<TerminalSession>(`/terminal/sessions/${encodeURIComponent(sessionId)}`);
}
/** Kill a running terminal session */
export function killTerminalSession(sessionId: string, signal?: "SIGTERM" | "SIGKILL" | "SIGINT"): Promise<{ killed: boolean; sessionId: string }> {
return api<{ killed: boolean; sessionId: string }>(`/terminal/sessions/${encodeURIComponent(sessionId)}/kill`, {
method: "POST",
body: JSON.stringify({ signal: signal ?? "SIGTERM" }),
});
}
/** Get the SSE stream URL for a terminal session */
export function getTerminalStreamUrl(sessionId: string): string {
return `/api/terminal/sessions/${encodeURIComponent(sessionId)}/stream`;
}
// --- PTY Terminal API (WebSocket-based) ---
/** PTY Terminal session response */
export interface PtyTerminalSession {
sessionId: string;
shell: string;
cwd: string;
}
/** PTY Terminal session info for listing */
export interface PtyTerminalSessionInfo {
id: string;
cwd: string;
shell: string;
createdAt: string;
}
/** Create a new PTY terminal session */
export function createTerminalSession(
cwd?: string,
cols?: number,
rows?: number
): Promise<PtyTerminalSession> {
return api<PtyTerminalSession>("/terminal/sessions", {
method: "POST",
body: JSON.stringify({ cwd, cols, rows }),
});
}
/** Kill a PTY terminal session */
export function killPtyTerminalSession(sessionId: string): Promise<{ killed: boolean }> {
return api<{ killed: boolean }>(`/terminal/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
}
/** List active PTY terminal sessions */
export function listTerminalSessions(): Promise<PtyTerminalSessionInfo[]> {
return api<PtyTerminalSessionInfo[]>("/terminal/sessions");
}
// --- Git Management API ---
/** Current git status */
export interface GitStatus {
branch: string;
commit: string;
isDirty: boolean;
ahead: number;
behind: number;
}
/** Git commit info */
export interface GitCommit {
hash: string;
shortHash: string;
message: string;
author: string;
date: string;
parents: string[];
}
/** Git branch info */
export interface GitBranch {
name: string;
isCurrent: boolean;
remote?: string;
lastCommitDate?: string;
}
/** Git worktree info */
export interface GitWorktree {
path: string;
branch?: string;
isMain: boolean;
isBare: boolean;
taskId?: string;
}
/** Result of a fetch operation */
export interface GitFetchResult {
fetched: boolean;
message: string;
}
/** Result of a pull operation */
export interface GitPullResult {
success: boolean;
message: string;
conflict?: boolean;
}
/** Result of a push operation */
export interface GitPushResult {
success: boolean;
message: string;
}
/** Fetch current git status */
export function fetchGitStatus(): Promise<GitStatus> {
return api<GitStatus>("/git/status");
}
/** Fetch recent commits */
export function fetchGitCommits(limit?: number): Promise<GitCommit[]> {
const query = limit ? `?limit=${limit}` : "";
return api<GitCommit[]>(`/git/commits${query}`);
}
/** Fetch diff for a specific commit */
export function fetchCommitDiff(hash: string): Promise<{ stat: string; patch: string }> {
return api<{ stat: string; patch: string }>(`/git/commits/${hash}/diff`);
}
/** Fetch all local branches */
export function fetchGitBranches(): Promise<GitBranch[]> {
return api<GitBranch[]>("/git/branches");
}
/** Fetch all worktrees */
export function fetchGitWorktrees(): Promise<GitWorktree[]> {
return api<GitWorktree[]>("/git/worktrees");
}
/** Create a new branch */
export function createBranch(name: string, base?: string): Promise<void> {
return api<void>("/git/branches", {
method: "POST",
body: JSON.stringify({ name, base }),
});
}
/** Checkout an existing branch */
export function checkoutBranch(name: string): Promise<void> {
return api<void>(`/git/branches/${encodeURIComponent(name)}/checkout`, {
method: "POST",
});
}
/** Delete a branch */
export function deleteBranch(name: string, force?: boolean): Promise<void> {
const query = force ? "?force=true" : "";
return api<void>(`/git/branches/${encodeURIComponent(name)}${query}`, {
method: "DELETE",
});
}
/** Fetch from remote */
export function fetchRemote(remote?: string): Promise<GitFetchResult> {
return api<GitFetchResult>("/git/fetch", {
method: "POST",
body: JSON.stringify({ remote }),
});
}
/** Pull current branch */
export function pullBranch(): Promise<GitPullResult> {
return api<GitPullResult>("/git/pull", {
method: "POST",
});
}
/** Push current branch */
export function pushBranch(): Promise<GitPushResult> {
return api<GitPushResult>("/git/push", {
method: "POST",
});
}
/** Git stash entry */
export interface GitStash {
index: number;
message: string;
date: string;
branch: string;
}
/** Individual file change with staging status */
export interface GitFileChange {
file: string;
status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked";
staged: boolean;
oldFile?: string;
}
/** Fetch stash list */
export function fetchGitStashList(): Promise<GitStash[]> {
return api<GitStash[]>("/git/stashes");
}
/** Create a new stash */
export function createStash(message?: string): Promise<{ message: string }> {
return api<{ message: string }>("/git/stashes", {
method: "POST",
body: JSON.stringify({ message }),
});
}
/** Apply a stash entry */
export function applyStash(index: number, drop?: boolean): Promise<{ message: string }> {
return api<{ message: string }>(`/git/stashes/${index}/apply`, {
method: "POST",
body: JSON.stringify({ drop }),
});
}
/** Drop a stash entry */
export function dropStash(index: number): Promise<{ message: string }> {
return api<{ message: string }>(`/git/stashes/${index}`, {
method: "DELETE",
});
}
/** Fetch unstaged diff (working directory changes) */
export function fetchUnstagedDiff(): Promise<{ stat: string; patch: string }> {
return api<{ stat: string; patch: string }>("/git/diff");
}
/** Fetch file changes (staged and unstaged) */
export function fetchFileChanges(): Promise<GitFileChange[]> {
return api<GitFileChange[]>("/git/changes");
}
/** Stage specific files */
export function stageFiles(files: string[]): Promise<{ staged: string[] }> {
return api<{ staged: string[] }>("/git/stage", {
method: "POST",
body: JSON.stringify({ files }),
});
}
/** Unstage specific files */
export function unstageFiles(files: string[]): Promise<{ unstaged: string[] }> {
return api<{ unstaged: string[] }>("/git/unstage", {
method: "POST",
body: JSON.stringify({ files }),
});
}
/** Create a commit */
export function createCommit(message: string): Promise<{ hash: string; message: string }> {
return api<{ hash: string; message: string }>("/git/commit", {
method: "POST",
body: JSON.stringify({ message }),
});
}
/** Discard changes in working directory for specific files */
export function discardChanges(files: string[]): Promise<{ discarded: string[] }> {
return api<{ discarded: string[] }>("/git/discard", {
method: "POST",
body: JSON.stringify({ files }),
});
}
// --- File Browser API ---
/** File node in directory listing */
export interface FileNode {
name: string;
type: "file" | "directory";
size?: number;
mtime?: string;
}
/** File listing response */
export interface FileListResponse {
path: string;
entries: FileNode[];
}
/** File content response */
export interface FileContentResponse {
content: string;
mtime: string;
size: number;
}
/** Save file response */
export interface SaveFileResponse {
success: true;
mtime: string;
size: number;
}
/** List files in task directory */
export function fetchFileList(taskId: string, path?: string): Promise<FileListResponse> {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return api<FileListResponse>(`/tasks/${taskId}/files${query}`);
}
/** Fetch file content */
export function fetchFileContent(taskId: string, filePath: string): Promise<FileContentResponse> {
return api<FileContentResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`);
}
/** Save file content */
export function saveFileContent(taskId: string, filePath: string, content: string): Promise<SaveFileResponse> {
return api<SaveFileResponse>(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, {
method: "POST",
body: JSON.stringify({ content }),
});
}
// --- Workspace File Browser API ---
export interface WorkspaceTaskInfo {
id: string;
title?: string;
worktree: string;
}
export interface WorkspaceListResponse {
project: string;
tasks: WorkspaceTaskInfo[];
}
/** Fetch available file browser workspaces. */
export function fetchWorkspaces(): Promise<WorkspaceListResponse> {
return api<WorkspaceListResponse>("/workspaces");
}
/** List files in a workspace (project root or task worktree). */
export function fetchWorkspaceFileList(workspace: string, path?: string): Promise<FileListResponse> {
const query = new URLSearchParams({ workspace });
if (path) {
query.set("path", path);
}
return api<FileListResponse>(`/files?${query.toString()}`);
}
/** Fetch file content from a workspace. */
export function fetchWorkspaceFileContent(workspace: string, filePath: string): Promise<FileContentResponse> {
const query = new URLSearchParams({ workspace });
return api<FileContentResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`);
}
/** Save file content to a workspace. */
export function saveWorkspaceFileContent(workspace: string, filePath: string, content: string): Promise<SaveFileResponse> {
const query = new URLSearchParams({ workspace });
return api<SaveFileResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, {
method: "POST",
body: JSON.stringify({ content }),
});
}
// --- Planning Mode API ---
/** Planning session state returned from API */
export interface PlanningSession {
sessionId: string;
currentQuestion: PlanningQuestion | null;
summary: PlanningSummary | null;
}
export interface SubtaskItem {
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}
/** SSE event types for planning session streaming */
export type PlanningStreamEvent =
| { type: "thinking"; data: string }
| { type: "question"; data: PlanningQuestion }
| { type: "summary"; data: PlanningSummary }
| { type: "error"; data: string }
| { type: "complete"; data: Record<string, never> };
/** Start a new planning session with an initial plan */
export function startPlanning(initialPlan: string, projectId?: string): Promise<PlanningSession> {
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
method: "POST",
body: JSON.stringify({ initialPlan }),
});
}
/** Start a new planning session with AI streaming support */
export function startPlanningStreaming(initialPlan: string, projectId?: string): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
method: "POST",
body: JSON.stringify({ initialPlan }),
});
}
/** Submit a response to the current planning question */
export function respondToPlanning(
sessionId: string,
responses: Record<string, unknown>,
projectId?: string
): Promise<PlanningSession> {
return api<PlanningSession>(withProjectId("/planning/respond", projectId), {
method: "POST",
body: JSON.stringify({ sessionId, responses }),
});
}
/** Cancel an active planning session */
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/planning/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}
/** Create a task from a completed planning session */
export function createTaskFromPlanning(sessionId: string, projectId?: string): Promise<Task> {
return api<Task>(withProjectId("/planning/create-task", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}
/** Get the SSE stream URL for a planning session */
export function getPlanningStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/planning/${encodeURIComponent(sessionId)}/stream`, projectId));
}
/** Connect to planning session SSE stream and handle events
*
* Returns an object with:
* - close: function to close the connection
* - reconnect: function to reconnect after error
*/
export function connectPlanningStream(
sessionId: string,
projectId: string | undefined,
handlers: {
onThinking?: (data: string) => void;
onQuestion?: (data: PlanningQuestion) => void;
onSummary?: (data: PlanningSummary) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
): { close: () => void; isConnected: () => boolean } {
const url = getPlanningStreamUrl(sessionId, projectId);
const eventSource = new EventSource(url);
let isClosed = false;
eventSource.onopen = () => {
isClosed = false;
};
eventSource.onmessage = (event) => {
// Handle comment events (heartbeats)
if (event.data.startsWith(":")) return;
};
// Handle specific event types
eventSource.addEventListener("thinking", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onThinking?.(data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("question", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningQuestion;
handlers.onQuestion?.(data);
} catch (err) {
console.error("[planning] Failed to parse question event:", err);
}
});
eventSource.addEventListener("summary", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data) as PlanningSummary;
handlers.onSummary?.(data);
} catch (err) {
console.error("[planning] Failed to parse summary event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
const data = JSON.parse(messageEvent.data);
handlers.onError?.(data.message || data);
} catch {
const messageEvent = event as MessageEvent;
handlers.onError?.(messageEvent.data || "Stream error");
}
close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
close();
});
// Handle connection errors
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
close();
}
};
function close() {
if (!isClosed) {
isClosed = true;
eventSource.close();
}
}
return {
close,
isConnected: () => !isClosed && eventSource.readyState === EventSource.OPEN,
};
}
// ── Automation / Scheduled Tasks ──────────────────────────────────
/** Response from the manual run trigger endpoint. */
export interface AutomationRunResponse {
schedule: ScheduledTask;
result: AutomationRunResult;
}
export function fetchAutomations(): Promise<ScheduledTask[]> {
return api<ScheduledTask[]>("/automations");
}
export function fetchAutomation(id: string): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}`);
}
export function createAutomation(input: ScheduledTaskCreateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = input;
return api<ScheduledTask>("/automations", {
method: "POST",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
});
}
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput): Promise<ScheduledTask> {
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = updates;
return api<ScheduledTask>(`/automations/${id}`, {
method: "PATCH",
body: JSON.stringify({ name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps }),
});
}
export async function deleteAutomation(id: string): Promise<void> {
await api(`/automations/${id}`, {
method: "DELETE",
});
}
export function runAutomation(id: string): Promise<AutomationRunResponse> {
return api<AutomationRunResponse>(`/automations/${id}/run`, {
method: "POST",
});
}
export function toggleAutomation(id: string): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}/toggle`, {
method: "POST",
});
}
export function reorderAutomationSteps(id: string, stepIds: string[]): Promise<ScheduledTask> {
return api<ScheduledTask>(`/automations/${id}/steps/reorder`, {
method: "POST",
body: JSON.stringify({ stepIds }),
});
}
// ── Activity Log API ────────────────────────────────────────────
/** Re-export ActivityLogEntry type from core for convenience */
export type { ActivityLogEntry, ActivityEventType } from "@fusion/core";
/** Fetch activity log entries */
export function fetchActivityLog(options?: { limit?: number; since?: string; type?: ActivityEventType }): Promise<ActivityLogEntry[]> {
const search = new URLSearchParams();
if (options?.limit !== undefined) search.set("limit", String(options.limit));
if (options?.since !== undefined) search.set("since", options.since);
if (options?.type !== undefined) search.set("type", options.type);
const suffix = search.size > 0 ? `?${search.toString()}` : "";
return api<ActivityLogEntry[]>(`/activity${suffix}`);
}
/** Clear all activity log entries */
export function clearActivityLog(): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/activity", { method: "DELETE" });
}
// ── Workflow Steps ─────────────────────────────────────────────────────
/** Fetch all workflow step definitions */
export function fetchWorkflowSteps(projectId?: string): Promise<WorkflowStep[]> {
return api<WorkflowStep[]>(withProjectId("/workflow-steps", projectId));
}
/** Create a new workflow step */
export function createWorkflowStep(input: WorkflowStepInput, projectId?: string): Promise<WorkflowStep> {
return api<WorkflowStep>(withProjectId("/workflow-steps", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update a workflow step */
export function updateWorkflowStep(id: string, updates: Partial<WorkflowStepInput>, projectId?: string): Promise<WorkflowStep> {
return api<WorkflowStep>(withProjectId(`/workflow-steps/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a workflow step */
export function deleteWorkflowStep(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/workflow-steps/${id}`, projectId), { method: "DELETE" });
}
/** Refine a workflow step's prompt using AI */
export function refineWorkflowStepPrompt(id: string, projectId?: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> {
return api<{ prompt: string; workflowStep: WorkflowStep }>(withProjectId(`/workflow-steps/${id}/refine`, projectId), {
method: "POST",
});
}
/** Fetch workflow step results for a task */
export function fetchWorkflowResults(taskId: string, projectId?: string): Promise<WorkflowStepResult[]> {
return api<WorkflowStepResult[]>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow-results`, projectId));
}
// ── Workflow Step Templates ──────────────────────────────────────────────
/** Re-export WorkflowStepTemplate type from core */
export type { WorkflowStepTemplate } from "@fusion/core";
/** Fetch all built-in workflow step templates */
export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@fusion/core").WorkflowStepTemplate[] }> {
return api<{ templates: import("@fusion/core").WorkflowStepTemplate[] }>("/workflow-step-templates");
}
/** Create a workflow step from a built-in template */
export function createWorkflowStepFromTemplate(templateId: string, projectId?: string): Promise<WorkflowStep> {
return api<WorkflowStep>(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), {
method: "POST",
});
}
// ── Scripts API ────────────────────────────────────────────────────────
/** Script entry returned from the API */
export interface ScriptEntry {
name: string;
command: string;
}
/** Result of running a script via POST /api/scripts/:name/run */
export interface ScriptRunResult {
sessionId: string;
command: string;
}
/** Fetch all saved scripts from project settings */
export function fetchScripts(projectId?: string): Promise<Record<string, string>> {
return api<Record<string, string>>(withProjectId("/scripts", projectId));
}
/** Add or update a script */
export function addScript(name: string, command: string, projectId?: string): Promise<ScriptEntry> {
return api<ScriptEntry>(withProjectId("/scripts", projectId), {
method: "POST",
body: JSON.stringify({ name, command }),
});
}
/** Remove a script by name */
export function removeScript(name: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/scripts/${encodeURIComponent(name)}`, projectId), { method: "DELETE" });
}
/** Run a saved script by name */
export function runScript(name: string, args?: string[], projectId?: string): Promise<ScriptRunResult> {
return api<ScriptRunResult>(withProjectId(`/scripts/${encodeURIComponent(name)}/run`, projectId), {
method: "POST",
body: JSON.stringify({ args }),
});
}
// ── AI Text Refinement API ────────────────────────────────────────────
/** Refinement types for AI text refinement */
export type RefinementType = "clarify" | "add-details" | "expand" | "simplify";
/** Response from text refinement endpoint */
export interface RefineTextResponse {
refined: string;
}
/**
* Refine task description text using AI.
* @param text - The text to refine (1-2000 characters)
* @param type - The refinement type: clarify, add-details, expand, or simplify
* @returns The refined text
* @throws Error with message for rate limit (429), invalid type (422), validation (400), or server errors
*/
export async function refineText(text: string, type: RefinementType): Promise<string> {
const response = await api<RefineTextResponse>("/ai/refine-text", {
method: "POST",
body: JSON.stringify({ text, type }),
});
return response.refined;
}
/**
* Error messages for refineText failures (to use with toast notifications).
*/
export const REFINE_ERROR_MESSAGES = {
/** Rate limit exceeded (429) */
RATE_LIMIT: "Too many refinement requests. Please wait an hour.",
/** Invalid refinement type (422) */
INVALID_TYPE: "Invalid refinement option selected.",
/** Network or server errors */
NETWORK: "Failed to refine text. Please try again.",
} as const;
/**
* Get user-friendly error message for a refineText error.
* @param error - The error thrown by refineText
* @returns A user-friendly error message suitable for toast display
*/
export function getRefineErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return REFINE_ERROR_MESSAGES.NETWORK;
}
const message = error.message.toLowerCase();
// Rate limit errors (429)
if (message.includes("rate limit") || message.includes("429")) {
return REFINE_ERROR_MESSAGES.RATE_LIMIT;
}
// Invalid type errors (422)
if (message.includes("invalid") && message.includes("type")) {
return REFINE_ERROR_MESSAGES.INVALID_TYPE;
}
// Text validation errors (400) - pass through from backend
if (
message.startsWith("text must") ||
message.includes("text is required") ||
message.includes("type is required")
) {
return error.message;
}
// Default network/server error
return REFINE_ERROR_MESSAGES.NETWORK;
}
export function startSubtaskBreakdown(description: string, projectId?: string): Promise<{ sessionId: string }> {
return api<{ sessionId: string }>(withProjectId("/subtasks/start-streaming", projectId), {
method: "POST",
body: JSON.stringify({ description }),
});
}
export function getSubtaskStreamUrl(sessionId: string, projectId?: string): string {
return buildApiUrl(withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/stream`, projectId));
}
export function connectSubtaskStream(
sessionId: string,
projectId: string | undefined,
handlers: {
onThinking?: (data: string) => void;
onSubtasks?: (data: SubtaskItem[]) => void;
onError?: (data: string) => void;
onComplete?: () => void;
}
): { close: () => void; isConnected: () => boolean } {
const eventSource = new EventSource(getSubtaskStreamUrl(sessionId, projectId));
let isClosed = false;
eventSource.onopen = () => {
isClosed = false;
};
eventSource.addEventListener("thinking", (event: Event) => {
const messageEvent = event as MessageEvent;
try {
handlers.onThinking?.(JSON.parse(messageEvent.data));
} catch {
handlers.onThinking?.(messageEvent.data);
}
});
eventSource.addEventListener("subtasks", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
handlers.onSubtasks?.(JSON.parse(messageEvent.data) as SubtaskItem[]);
} catch (err) {
console.error("[subtasks] Failed to parse subtasks event:", err);
}
});
eventSource.addEventListener("error", (event: Event) => {
try {
const messageEvent = event as MessageEvent;
handlers.onError?.(JSON.parse(messageEvent.data) as string);
} catch {
handlers.onError?.("Stream error");
}
isClosed = true;
eventSource.close();
});
eventSource.addEventListener("complete", () => {
handlers.onComplete?.();
isClosed = true;
eventSource.close();
});
eventSource.onerror = () => {
if (!isClosed) {
handlers.onError?.("Connection lost");
}
isClosed = true;
eventSource.close();
};
return {
close: () => {
isClosed = true;
eventSource.close();
},
isConnected: () => !isClosed,
};
}
export function createTasksFromBreakdown(
sessionId: string,
subtasks: SubtaskItem[],
parentTaskId?: string,
projectId?: string,
): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> {
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), {
method: "POST",
body: JSON.stringify({
sessionId,
parentTaskId,
subtasks: subtasks.map((subtask) => ({
tempId: subtask.id,
title: subtask.title,
description: subtask.description,
size: subtask.suggestedSize,
dependsOn: subtask.dependsOn,
})),
}),
});
}
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/subtasks/cancel", projectId), {
method: "POST",
body: JSON.stringify({ sessionId }),
});
}
// ── Agent API ────────────────────────────────────────────────────────────
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput };
function withProjectId(path: string, projectId?: string): string {
if (!projectId) return path;
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
}
/** Fetch all agents, optionally filtered by state or role */
export function fetchAgents(
filter?: { state?: AgentState; role?: AgentCapability },
projectId?: string,
): Promise<Agent[]> {
const params = new URLSearchParams();
if (filter?.state) params.set("state", filter.state);
if (filter?.role) params.set("role", filter.role);
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<Agent[]>(`/agents${query}`);
}
/** Fetch a single agent with heartbeat history */
export function fetchAgent(agentId: string, projectId?: string): Promise<AgentDetail> {
return api<AgentDetail>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId));
}
/** Create a new agent */
export function createAgent(input: AgentCreateInput, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId("/agents", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update an agent */
export function updateAgent(agentId: string, updates: AgentUpdateInput, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
method: "POST",
body: JSON.stringify({ state }),
});
}
/** Delete an agent */
export function deleteAgent(agentId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}`, projectId), {
method: "DELETE",
});
}
/** Record a heartbeat for an agent */
export function recordAgentHeartbeat(
agentId: string,
status: "ok" | "missed" | "recovered" = "ok",
projectId?: string,
): Promise<AgentHeartbeatEvent> {
return api<AgentHeartbeatEvent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/heartbeat`, projectId), {
method: "POST",
body: JSON.stringify({ status }),
});
}
/** Fetch heartbeat history for an agent */
export function fetchAgentHeartbeats(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatEvent[]> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<AgentHeartbeatEvent[]>(`/agents/${encodeURIComponent(agentId)}/heartbeats${query}`);
}
// --- Backup API ---
/** Backup metadata from the API */
export interface BackupInfo {
filename: string;
createdAt: string;
size: number;
path: string;
}
/** Result of listing backups */
export interface BackupListResponse {
backups: BackupInfo[];
count: number;
totalSize: number;
}
/** Result of creating a backup */
export interface BackupCreateResponse {
success: boolean;
backupPath?: string;
output?: string;
deletedCount?: number;
error?: string;
}
/** Fetch all database backups */
export function fetchBackups(projectId?: string): Promise<BackupListResponse> {
return api<BackupListResponse>(withProjectId("/backups", projectId));
}
/** Create a new database backup immediately */
export function createBackup(projectId?: string): Promise<BackupCreateResponse> {
return api<BackupCreateResponse>(withProjectId("/backups", projectId), { method: "POST" });
}
// --- Settings Export/Import API ---
/** Exported settings data structure */
export interface SettingsExportData {
version: 1;
exportedAt: string;
source?: string;
global?: GlobalSettings;
project?: Partial<ProjectSettings>;
}
/** Result of importing settings */
export interface SettingsImportResponse {
success: boolean;
globalCount: number;
projectCount: number;
error?: string;
}
/** Export settings as JSON */
export function exportSettings(scope?: 'global' | 'project' | 'both', projectId?: string): Promise<SettingsExportData> {
const path = withProjectId("/settings/export", projectId);
const scopedPath = scope ? `${path}${path.includes("?") ? "&" : "?"}scope=${encodeURIComponent(scope)}` : path;
return api<SettingsExportData>(scopedPath);
}
/** Import settings from JSON data */
export function importSettings(
data: SettingsExportData,
options?: { scope?: 'global' | 'project' | 'both'; merge?: boolean },
projectId?: string
): Promise<SettingsImportResponse> {
return api<SettingsImportResponse>(withProjectId("/settings/import", projectId), {
method: "POST",
body: JSON.stringify({
data,
scope: options?.scope ?? "both",
merge: options?.merge ?? true,
}),
});
}
// --- AI Summarization API ---
/** Response from title summarization endpoint */
export interface SummarizeTitleResponse {
title: string;
}
/** Summarize a task description into a concise title using AI.
* @param description - The task description to summarize (must be 141-2000 chars)
* @param provider - Optional AI model provider (e.g., "anthropic")
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
* @returns The generated title (guaranteed ≤60 characters)
* @throws Error with descriptive message for 400/429/503 errors
*/
export async function summarizeTitle(
description: string,
provider?: string,
modelId?: string
): Promise<string> {
const res = await fetch("/api/ai/summarize-title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description, provider, modelId }),
});
const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text();
const isJson = contentType.includes("application/json");
if (!isJson) {
throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`);
}
const data = JSON.parse(bodyText) as { title?: string; error?: string };
if (!res.ok) {
const errorMessage = data.error || "Request failed";
if (res.status === 400) {
throw new Error(`Invalid request: ${errorMessage}`);
} else if (res.status === 429) {
throw new Error(`Rate limit exceeded: ${errorMessage}`);
} else if (res.status === 503) {
throw new Error(`AI service temporarily unavailable: ${errorMessage}`);
} else {
throw new Error(errorMessage);
}
}
if (!data.title) {
throw new Error("API returned empty title");
}
return data.title;
}
// ── Project Management API (Multi-Project Support) ───────────────────────
/** Project information returned by project endpoints */
export interface ProjectInfo {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
}
/** Project health metrics */
export interface ProjectHealth {
projectId: string;
status: "active" | "paused" | "errored" | "initializing";
activeTaskCount: number;
inFlightAgentCount: number;
lastActivityAt?: string;
lastErrorAt?: string;
lastErrorMessage?: string;
totalTasksCompleted: number;
totalTasksFailed: number;
averageTaskDurationMs?: number;
updatedAt: string;
}
/** Executor state values */
export type ExecutorState = "idle" | "running" | "paused";
/** Aggregated executor statistics for the status bar.
*
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount)
* are derived client-side from the same tasks array shared with the board, ensuring
* the footer counts always match the column counts displayed on screen.
* The API returns settings-based values (globalPause, enginePaused, maxConcurrent) and
* lastActivityAt from the activity log.
*
* The executorState is derived from:
* - "idle": globalPause is true OR (enginePaused is true AND runningTaskCount is 0)
* - "paused": enginePaused is true AND runningTaskCount > 0
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0
*/
export interface ExecutorStats {
/** Number of tasks currently in "in-progress" column */
runningTaskCount: number;
/** Number of tasks with blockedBy field set (waiting on file overlap) */
blockedTaskCount: number;
/** Number of "in-progress" tasks with no activity for > 10 minutes */
stuckTaskCount: number;
/** Number of tasks in "todo" column */
queuedTaskCount: number;
/** Number of tasks in "in-review" column */
inReviewCount: number;
/** Derived executor state: "idle", "running", or "paused" */
executorState: ExecutorState;
/** Maximum concurrent tasks allowed from settings */
maxConcurrent: number;
/** ISO timestamp of most recent task event from activity log */
lastActivityAt?: string;
}
/** Unified activity feed entry */
export interface ActivityFeedEntry {
id: string;
timestamp: string;
type: "task:created" | "task:moved" | "task:updated" | "task:deleted" | "task:merged" | "task:failed" | "settings:updated";
projectId: string;
projectName: string;
taskId?: string;
taskTitle?: string;
details: string;
metadata?: Record<string, unknown>;
}
/** Input for creating a new project */
export interface ProjectCreateInput {
name: string;
path: string;
isolationMode?: "in-process" | "child-process";
}
/** Options for fetching activity feed */
export interface FeedOptions {
limit?: number;
since?: string;
projectId?: string;
type?: ActivityFeedEntry["type"];
}
/** Global concurrency state across all projects */
export interface GlobalConcurrencyState {
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
projectsActive: Record<string, number>;
}
/** First run status response */
export interface FirstRunStatus {
hasProjects: boolean;
singleProjectPath: string | null;
}
/** Setup state for first-run wizard */
export interface SetupState {
/** The first-run state: fresh-install, needs-migration, setup-wizard, normal-operation */
state: "fresh-install" | "needs-migration" | "setup-wizard" | "normal-operation";
/** Projects detected on the filesystem (not yet registered) */
detectedProjects: Array<{
path: string;
name: string;
hasDb: boolean;
}>;
/** Whether the central database exists */
hasCentralDb: boolean;
/** Projects already registered in the central database */
registeredProjects: Array<{
id: string;
name: string;
path: string;
}>;
}
/** Input for completing setup */
export interface CompleteSetupInput {
projects: Array<{
path: string;
name: string;
isolationMode?: "in-process" | "child-process";
}>;
}
/** Result of completing setup */
export interface CompleteSetupResult {
success: boolean;
projectsRegistered: string[];
errors: string[];
}
/** Fetch all registered projects */
export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects");
}
/** Browse directory entries for the directory picker */
export interface BrowseDirectoryResult {
currentPath: string;
parentPath: string | null;
entries: Array<{ name: string; path: string; hasChildren: boolean }>;
}
export function browseDirectory(path?: string, showHidden?: boolean): Promise<BrowseDirectoryResult> {
const params = new URLSearchParams();
if (path) params.set("path", path);
if (showHidden) params.set("showHidden", "true");
const qs = params.toString();
return api<BrowseDirectoryResult>(`/browse-directory${qs ? `?${qs}` : ""}`);
}
/** Register a new project */
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
return api<ProjectInfo>("/projects", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Unregister a project */
export function unregisterProject(id: string): Promise<void> {
return api<void>(`/projects/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** Fetch health metrics for a specific project */
export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
}
/** Fetch executor statistics for the status bar.
*
* Returns settings-based values and lastActivityAt.
* Counts are derived client-side from the tasks array.
*/
export function fetchExecutorStats(projectId?: string): Promise<{
globalPause: boolean;
enginePaused: boolean;
maxConcurrent: number;
lastActivityAt?: string;
}> {
return api<{
globalPause: boolean;
enginePaused: boolean;
maxConcurrent: number;
lastActivityAt?: string;
}>(withProjectId("/executor/stats", projectId));
}
/** Fetch unified activity feed */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.since) params.set("since", options.since);
if (options?.projectId) params.set("projectId", options.projectId);
if (options?.type) params.set("type", options.type);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<ActivityFeedEntry[]>(`/activity-feed${query}`);
}
/** Pause a project */
export function pauseProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/pause`, {
method: "POST",
});
}
/** Resume a paused project */
export function resumeProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/resume`, {
method: "POST",
});
}
/** Fetch first run status to detect if user needs setup wizard */
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
return api<FirstRunStatus>("/first-run-status");
}
/** Fetch detailed setup state including detected projects */
export function fetchSetupState(): Promise<SetupState> {
return api<SetupState>("/setup-state");
}
/** Complete first-run setup by registering projects */
export function completeSetup(input: CompleteSetupInput): Promise<CompleteSetupResult> {
return api<CompleteSetupResult>("/complete-setup", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Fetch global concurrency state */
export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
return api<GlobalConcurrencyState>("/global-concurrency");
}
/** Fetch tasks for a specific project */
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
const params = new URLSearchParams();
params.set("projectId", projectId);
if (limit !== undefined) params.set("limit", String(limit));
if (offset !== undefined) params.set("offset", String(offset));
return api<Task[]>(`/tasks?${params.toString()}`);
}
/** Fetch project-specific config */
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
}
/** Detected project information */
export interface DetectedProject {
path: string;
suggestedName: string;
existing: boolean;
}
/** Detect projects in a base path */
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
return api<{ projects: DetectedProject[] }>("/projects/detect", {
method: "POST",
body: JSON.stringify({ basePath }),
});
}
/** Fetch a single project by ID */
export function fetchProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
}
/** Update an existing project */
export function updateProject(id: string, updates: Partial<ProjectInfo>): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
// ── Task Diff API ──────────────────────────────────────────────────────────
/** Task diff information */
export interface TaskDiff {
files: Array<{
path: string;
status: "added" | "modified" | "deleted";
additions: number;
deletions: number;
patch: string;
}>;
stats: {
filesChanged: number;
additions: number;
deletions: number;
};
}
/** Fetch diff for a task's changes */
export function fetchTaskDiff(taskId: string, worktree?: string, projectId?: string): Promise<TaskDiff> {
const params = new URLSearchParams();
if (worktree) params.set("worktree", worktree);
if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff${query}`);
}
/** Individual file diff */
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
diff: string;
oldPath?: string;
}
/** Fetch file diffs for a task */
export function fetchTaskFileDiffs(taskId: string, projectId?: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/file-diffs`, projectId));
}
// ── Mission API ───────────────────────────────────────────────────────────
/** Mission status values */
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
/** Milestone status values */
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
/** Slice status values */
export type SliceStatus = "pending" | "active" | "complete";
/** Feature status values */
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Mission entity */
export interface Mission {
id: string;
title: string;
description?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
createdAt: string;
updatedAt: string;
}
/** Milestone entity */
export interface Milestone {
id: string;
missionId: string;
title: string;
description?: string;
status: MilestoneStatus;
orderIndex: number;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
dependencies: string[];
createdAt: string;
updatedAt: string;
}
/** Slice entity */
export interface Slice {
id: string;
milestoneId: string;
title: string;
description?: string;
status: SliceStatus;
orderIndex: number;
activatedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Feature entity */
export interface MissionFeature {
id: string;
sliceId: string;
taskId?: string;
title: string;
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
createdAt: string;
updatedAt: string;
}
/** Milestone with slices (each slice has features) */
export interface MilestoneWithSlices extends Milestone {
slices: SliceWithFeatures[];
}
/** Slice with features */
export interface SliceWithFeatures extends Slice {
features: MissionFeature[];
}
/** Full mission hierarchy */
export interface MissionWithHierarchy extends Mission {
milestones: MilestoneWithSlices[];
}
/** Fetch all missions */
export function fetchMissions(projectId?: string): Promise<Mission[]> {
return api<Mission[]>(withProjectId("/missions", projectId));
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Get mission with full hierarchy */
export function fetchMission(missionId: string, projectId?: string): Promise<MissionWithHierarchy> {
return api<MissionWithHierarchy>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId));
}
/** Update mission */
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete mission */
export function deleteMission(missionId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
method: "DELETE",
});
}
/** Get mission computed status */
export function fetchMissionStatus(missionId: string, projectId?: string): Promise<{ status: string }> {
return api<{ status: string }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/status`, projectId));
}
/** Add milestone to mission */
export function createMilestone(
missionId: string,
input: { title: string; description?: string; dependencies?: string[] },
projectId?: string
): Promise<Milestone> {
return api<Milestone>(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone */
export function updateMilestone(milestoneId: string, updates: Partial<Milestone>, projectId?: string): Promise<Milestone> {
return api<Milestone>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete milestone */
export function deleteMilestone(milestoneId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
method: "DELETE",
});
}
/** Reorder milestones */
export function reorderMilestones(missionId: string, orderedIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add slice to milestone */
export function createSlice(
milestoneId: string,
input: { title: string; description?: string },
projectId?: string
): Promise<Slice> {
return api<Slice>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update slice */
export function updateSlice(sliceId: string, updates: Partial<Slice>, projectId?: string): Promise<Slice> {
return api<Slice>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete slice */
export function deleteSlice(sliceId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}`, projectId), {
method: "DELETE",
});
}
/** Activate slice */
export function activateSlice(sliceId: string, projectId?: string): Promise<Slice> {
return api<Slice>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, projectId), {
method: "POST",
});
}
/** Reorder slices */
export function reorderSlices(milestoneId: string, orderedIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add feature to slice */
export function createFeature(
sliceId: string,
input: { title: string; description?: string; acceptanceCriteria?: string },
projectId?: string
): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/features`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature */
export function updateFeature(featureId: string, updates: Partial<MissionFeature>, projectId?: string): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete feature */
export function deleteFeature(featureId: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}`, projectId), {
method: "DELETE",
});
}
/** Link feature to task */
export function linkFeatureToTask(featureId: string, taskId: string, projectId?: string): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/link-task`, projectId), {
method: "POST",
body: JSON.stringify({ taskId }),
});
}
/** Unlink feature from task */
export function unlinkFeatureFromTask(featureId: string, projectId?: string): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, projectId), {
method: "POST",
});
}