Implements durable branch context tracking across the mission planning lifecycle (FN-3216): adds branch context persistence to the core store and mission store, propagates branch context through planning subtasks into triaged tasks, and exposes the merge context via dashboard API routes with tests c Fusion-Task-Id: FN-3216
9147 lines
300 KiB
TypeScript
9147 lines
300 KiB
TypeScript
import type {
|
|
Task,
|
|
TaskDetail,
|
|
TaskReviewData,
|
|
TaskAttachment,
|
|
TaskComment,
|
|
TaskCreateInput,
|
|
AgentLogEntry,
|
|
Column,
|
|
MergeResult,
|
|
Settings,
|
|
GlobalSettings,
|
|
ProjectSettings,
|
|
BatchStatusResult,
|
|
BatchStatusResponse,
|
|
ActivityLogEntry,
|
|
ActivityEventType,
|
|
WorkflowStep,
|
|
WorkflowStepInput,
|
|
WorkflowStepResult,
|
|
PluginInstallation,
|
|
PluginSetupCheckResult,
|
|
PluginUiSlotDefinition,
|
|
PluginUiContributionDefinition,
|
|
PluginDashboardViewDefinition,
|
|
TaskDocument,
|
|
TaskDocumentRevision,
|
|
TaskDocumentWithTask,
|
|
|
|
Message,
|
|
MessageMetadata,
|
|
MessageType,
|
|
ParticipantType,
|
|
NodeConfig,
|
|
NodeStatus,
|
|
NodeMeshState,
|
|
SystemMetrics,
|
|
DiscoveryConfig,
|
|
MissionEvent,
|
|
MissionHealth,
|
|
MissionEventType,
|
|
AgentRating,
|
|
AgentRatingSummary,
|
|
AgentRatingInput,
|
|
ChatAttachment,
|
|
ChatMessage,
|
|
ChatRoom,
|
|
ChatRoomMember,
|
|
ChatRoomMessage,
|
|
EnrichedChatSession,
|
|
TodoList,
|
|
TodoItem,
|
|
TodoListWithItems,
|
|
TodoListCreateInput,
|
|
TodoListUpdateInput,
|
|
TodoItemCreateInput,
|
|
TodoItemUpdateInput,
|
|
Insight,
|
|
InsightCategory,
|
|
InsightStatus,
|
|
InsightRun,
|
|
InsightRunTrigger,
|
|
EvalRun,
|
|
EvalTaskResult,
|
|
ResearchRunStatus,
|
|
TaskPriority,
|
|
TaskSourceIssue,
|
|
ManagedDockerNodeInput,
|
|
DockerNodeConfig,
|
|
DockerHostConfig,
|
|
DockerResourceSizing,
|
|
DockerVolumeMount,
|
|
DockerExtraCli,
|
|
DockerNodeStatus,
|
|
ProjectNodePathMapping,
|
|
ApprovalRequestStatus,
|
|
} from "@fusion/core";
|
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
|
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
|
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
|
|
import type { MilestoneValidationTelemetry } from "../components/mission-types";
|
|
import type {
|
|
ResearchAvailability,
|
|
ResearchRunDetail,
|
|
ResearchRunsResponse,
|
|
ResearchRunResponse,
|
|
ResearchProviderOption,
|
|
} from "../research-types";
|
|
import { appendTokenQuery, getAuthToken, withTokenHeader } from "../auth";
|
|
|
|
// Re-export skills types for use by hooks and components
|
|
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry };
|
|
|
|
export class ApiRequestError extends Error {
|
|
readonly status: number;
|
|
readonly details?: Record<string, unknown>;
|
|
|
|
constructor(message: string, status: number, details?: Record<string, unknown>) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
export interface DeleteTaskOptions {
|
|
removeDependencyReferences?: boolean;
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
const url = buildApiUrl(path);
|
|
const token = getAuthToken();
|
|
const headers = (() => {
|
|
if (token) {
|
|
const authenticatedHeaders = new Headers(opts.headers ?? {});
|
|
if (!authenticatedHeaders.has("Content-Type")) {
|
|
authenticatedHeaders.set("Content-Type", "application/json");
|
|
}
|
|
return withTokenHeader(authenticatedHeaders);
|
|
}
|
|
|
|
if (!opts.headers) {
|
|
return { "Content-Type": "application/json" };
|
|
}
|
|
|
|
const defaultHeaders = new Headers(opts.headers);
|
|
if (!defaultHeaders.has("Content-Type")) {
|
|
defaultHeaders.set("Content-Type", "application/json");
|
|
}
|
|
return Object.fromEntries(defaultHeaders.entries());
|
|
})();
|
|
|
|
const res = await fetch(url, {
|
|
...opts,
|
|
headers,
|
|
});
|
|
|
|
// Handle successful 204 No Content responses (e.g., DELETE, reorder)
|
|
// These return no body and no JSON content-type — return undefined for void endpoints
|
|
if (res.status === 204) {
|
|
if (!res.ok) {
|
|
// 204 is always ok by definition, but guard anyway
|
|
throw new Error(`Request failed for ${url}: ${res.status} ${res.statusText}`);
|
|
}
|
|
return undefined as T;
|
|
}
|
|
|
|
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) {
|
|
const payload = data as { error?: string; details?: Record<string, unknown> } | null;
|
|
throw new ApiRequestError(
|
|
payload?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`,
|
|
res.status,
|
|
payload?.details,
|
|
);
|
|
}
|
|
|
|
return data as T;
|
|
}
|
|
|
|
export interface DashboardHealthResponse {
|
|
status: string;
|
|
version: string;
|
|
uptime: number;
|
|
}
|
|
|
|
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
|
return api<DashboardHealthResponse>("/health");
|
|
}
|
|
|
|
export function checkForUpdates(): Promise<UpdateCheckResponse> {
|
|
return api<UpdateCheckResponse>("/updates/check");
|
|
}
|
|
|
|
export function fetchTasks(
|
|
limit?: number,
|
|
offset?: number,
|
|
projectId?: string,
|
|
q?: string,
|
|
includeArchived?: boolean,
|
|
): 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);
|
|
if (q) search.set("q", q);
|
|
if (includeArchived) search.set("includeArchived", "1");
|
|
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: withTokenHeader({ "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 interface UpdateTaskReviewRequest {
|
|
reviewState: TaskDetail["reviewState"] | null;
|
|
}
|
|
|
|
export interface TaskReviewResponse {
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
automationStatus: string | null;
|
|
emptyMessage?: string | null;
|
|
prInfo?: TaskDetail["prInfo"];
|
|
}
|
|
|
|
export interface RefreshTaskReviewResponse {
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
automationStatus: string | null;
|
|
prInfo?: TaskDetail["prInfo"];
|
|
}
|
|
|
|
export interface SelectedReviewItem {
|
|
id: string;
|
|
source: "pr-review" | "reviewer-agent";
|
|
threadId?: string;
|
|
filePath?: string;
|
|
lineNumber?: number;
|
|
author?: string;
|
|
summary: string;
|
|
body: string;
|
|
url?: string;
|
|
}
|
|
|
|
export interface ReviseTaskReviewResponse {
|
|
task: Task;
|
|
reviewState: NonNullable<TaskDetail["reviewState"]>;
|
|
}
|
|
|
|
export interface CreateTaskRequestOptions {
|
|
transportNodeId?: string;
|
|
localNodeId?: string;
|
|
}
|
|
|
|
export function createTask(
|
|
input: TaskCreateInput,
|
|
projectId?: string,
|
|
options?: CreateTaskRequestOptions,
|
|
): Promise<Task> {
|
|
const {
|
|
title,
|
|
description,
|
|
column,
|
|
dependencies,
|
|
breakIntoSubtasks,
|
|
enabledWorkflowSteps,
|
|
assignedAgentId,
|
|
modelPresetId,
|
|
modelProvider,
|
|
modelId,
|
|
validatorModelProvider,
|
|
validatorModelId,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
thinkingLevel,
|
|
summarize,
|
|
reviewLevel,
|
|
executionMode,
|
|
priority,
|
|
source,
|
|
nodeId,
|
|
branch,
|
|
baseBranch,
|
|
} = input;
|
|
|
|
return proxyApi<Task>(withProjectId("/tasks", projectId), {
|
|
method: "POST",
|
|
nodeId: options?.transportNodeId,
|
|
localNodeId: options?.localNodeId,
|
|
body: JSON.stringify({
|
|
title,
|
|
description,
|
|
column,
|
|
dependencies,
|
|
breakIntoSubtasks,
|
|
enabledWorkflowSteps,
|
|
assignedAgentId,
|
|
modelPresetId,
|
|
modelProvider,
|
|
modelId,
|
|
validatorModelProvider,
|
|
validatorModelId,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
thinkingLevel,
|
|
summarize,
|
|
reviewLevel,
|
|
executionMode,
|
|
priority,
|
|
source,
|
|
nodeId,
|
|
branch,
|
|
baseBranch,
|
|
}),
|
|
});
|
|
}
|
|
|
|
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;
|
|
planningModelProvider?: string | null;
|
|
planningModelId?: string | null;
|
|
thinkingLevel?: string | null;
|
|
reviewLevel?: number | null;
|
|
executionMode?: "standard" | "fast" | null;
|
|
priority?: TaskPriority | null;
|
|
sourceIssue?: TaskSourceIssue | null;
|
|
nodeId?: string | null;
|
|
branch?: string | null;
|
|
baseBranch?: 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,
|
|
planningModelProvider?: string | null,
|
|
planningModelId?: string | null,
|
|
nodeId?: 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,
|
|
planningModelProvider,
|
|
planningModelId,
|
|
nodeId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function moveTask(
|
|
id: string,
|
|
column: Column,
|
|
projectId?: string,
|
|
optionsOrPosition?: { preserveProgress?: boolean } | number,
|
|
): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/move`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
column,
|
|
...(
|
|
typeof optionsOrPosition === "object" && optionsOrPosition?.preserveProgress
|
|
? { preserveProgress: true }
|
|
: {}
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function deleteTask(id: string, projectId?: string, options?: DeleteTaskOptions): Promise<Task> {
|
|
const search = new URLSearchParams();
|
|
if (options?.removeDependencyReferences) {
|
|
search.set("removeDependencyReferences", "true");
|
|
}
|
|
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<Task>(withProjectId(`/tasks/${id}${suffix}`, 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 resetTask(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/reset`, projectId), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirm: true }),
|
|
});
|
|
}
|
|
|
|
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),
|
|
});
|
|
}
|
|
|
|
export interface UpdateCheckResponse {
|
|
currentVersion: string;
|
|
latestVersion: string | null;
|
|
updateAvailable: boolean;
|
|
lastChecked?: number;
|
|
disabled?: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export function checkForUpdate(projectId?: string): Promise<UpdateCheckResponse> {
|
|
return api<UpdateCheckResponse>(withProjectId("/update-check", projectId));
|
|
}
|
|
|
|
export function refreshUpdateCheck(projectId?: string): Promise<UpdateCheckResponse> {
|
|
return api<UpdateCheckResponse>(withProjectId("/update-check/refresh", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export interface RemoteSettings {
|
|
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
|
remoteTailscaleEnabled: boolean;
|
|
remoteTailscaleHostname: string;
|
|
remoteTailscaleTargetPort: number;
|
|
remoteTailscaleAcceptRoutes: boolean;
|
|
remoteCloudflareEnabled: boolean;
|
|
remoteCloudflareQuickTunnel: boolean;
|
|
remoteCloudflareTunnelName: string;
|
|
remoteCloudflareTunnelToken: string | null;
|
|
remoteCloudflareIngressUrl: string;
|
|
remotePersistentToken: string | null;
|
|
remoteShortLivedEnabled: boolean;
|
|
remoteShortLivedTtlMs: number;
|
|
remoteShortLivedMaxTtlMs: number;
|
|
remoteRememberLastRunning: boolean;
|
|
remoteWasRunningOnShutdown: boolean;
|
|
remoteLastStartedProvider: "tailscale" | "cloudflare" | null;
|
|
}
|
|
|
|
export interface RemoteStatus {
|
|
provider: "tailscale" | "cloudflare" | null;
|
|
state: "stopped" | "starting" | "running" | "stopping" | "failed";
|
|
url: string | null;
|
|
lastError: string | null;
|
|
lastErrorCode?: string | null;
|
|
cloudflaredAvailable?: boolean | null;
|
|
externalTunnel?: {
|
|
provider: "tailscale" | "cloudflare";
|
|
url: string | null;
|
|
} | null;
|
|
restore?: {
|
|
outcome: "applied" | "skipped" | "failed";
|
|
reason: string;
|
|
at: string;
|
|
provider: "tailscale" | "cloudflare" | null;
|
|
message?: string;
|
|
};
|
|
}
|
|
|
|
export function fetchRemoteSettings(projectId?: string): Promise<{ settings: RemoteSettings }> {
|
|
return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId));
|
|
}
|
|
|
|
export function updateRemoteSettings(
|
|
settings: Partial<RemoteSettings>,
|
|
projectId?: string,
|
|
): Promise<{ settings: RemoteSettings }> {
|
|
return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify(settings),
|
|
});
|
|
}
|
|
|
|
export function fetchRemoteStatus(projectId?: string): Promise<RemoteStatus> {
|
|
return api<RemoteStatus>(withProjectId("/remote/status", projectId));
|
|
}
|
|
|
|
export function installCloudflared(projectId?: string): Promise<{ success: boolean; command: string; error?: string }> {
|
|
return api(withProjectId("/remote/install-cloudflared", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function activateRemoteProvider(provider: "tailscale" | "cloudflare", projectId?: string): Promise<{ activeProvider: "tailscale" | "cloudflare" }> {
|
|
return api<{ activeProvider: "tailscale" | "cloudflare" }>(withProjectId("/remote/provider/activate", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider }),
|
|
});
|
|
}
|
|
|
|
export function startRemoteTunnel(projectId?: string): Promise<{ state: "starting" | "running"; provider: string }> {
|
|
return api<{ state: "starting" | "running"; provider: string }>(withProjectId("/remote/tunnel/start", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function stopRemoteTunnel(projectId?: string): Promise<{ state: "stopped"; provider: string | null }> {
|
|
return api<{ state: "stopped"; provider: string | null }>(withProjectId("/remote/tunnel/stop", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function killExternalTunnel(projectId?: string): Promise<{ ok: boolean }> {
|
|
return api<{ ok: boolean }>(withProjectId("/remote/tunnel/kill-external", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function regenerateRemotePersistentToken(projectId?: string): Promise<{ token: string; maskedToken: string }> {
|
|
return api<{ token: string; maskedToken: string }>(withProjectId("/remote/token/persistent/regenerate", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function generateShortLivedRemoteToken(ttlMs: number, projectId?: string): Promise<{ token: string; expiresAt: string; ttlMs: number }> {
|
|
return api<{ token: string; expiresAt: string; ttlMs: number }>(withProjectId("/remote/token/short-lived/generate", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ ttlMs }),
|
|
});
|
|
}
|
|
|
|
type RemoteAuthTokenType = "persistent" | "short-lived";
|
|
|
|
type RemoteLinkRequestOptions = {
|
|
projectId?: string;
|
|
tokenType?: RemoteAuthTokenType;
|
|
ttlMs?: number;
|
|
};
|
|
|
|
function buildRemoteAuthQuery(
|
|
format: "text" | "image/svg" | null,
|
|
tokenType: RemoteAuthTokenType,
|
|
ttlMs?: number,
|
|
): string {
|
|
const params = new URLSearchParams();
|
|
if (format) {
|
|
params.set("format", format);
|
|
}
|
|
params.set("tokenType", tokenType);
|
|
if (tokenType === "short-lived" && typeof ttlMs === "number" && Number.isFinite(ttlMs)) {
|
|
params.set("ttlMs", String(ttlMs));
|
|
}
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
export function fetchRemoteUrl(
|
|
options: RemoteLinkRequestOptions = {},
|
|
): Promise<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null }> {
|
|
const { projectId, tokenType = "persistent", ttlMs } = options;
|
|
const query = buildRemoteAuthQuery(null, tokenType, ttlMs);
|
|
return api<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null }>(withProjectId(`/remote/url${query}`, projectId));
|
|
}
|
|
|
|
export function fetchRemoteQr(
|
|
format: "text" | "image/svg" = "text",
|
|
options: RemoteLinkRequestOptions = {},
|
|
): Promise<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null; format: "text" | "image/svg"; data?: string }> {
|
|
const { projectId, tokenType = "persistent", ttlMs } = options;
|
|
const query = buildRemoteAuthQuery(format, tokenType, ttlMs);
|
|
return api<{ url: string; tokenType: RemoteAuthTokenType; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>(withProjectId(`/remote/qr${query}`, projectId));
|
|
}
|
|
|
|
export function fetchMemory(projectId?: string): Promise<{ content: string }> {
|
|
return api<{ content: string }>(withProjectId("/memory", projectId));
|
|
}
|
|
|
|
export function saveMemory(content: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId("/memory", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
export interface MemoryFileInfo {
|
|
path: string;
|
|
label: string;
|
|
layer: "long-term" | "daily" | "dreams";
|
|
size: number;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export function fetchMemoryFiles(projectId?: string): Promise<{ files: MemoryFileInfo[] }> {
|
|
return api<{ files: MemoryFileInfo[] }>(withProjectId("/memory/files", projectId));
|
|
}
|
|
|
|
export function fetchMemoryFile(path: string, projectId?: string): Promise<{ path: string; content: string }> {
|
|
const query = `path=${encodeURIComponent(path)}`;
|
|
return api<{ path: string; content: string }>(withProjectId(`/memory/file?${query}`, projectId));
|
|
}
|
|
|
|
export function saveMemoryFile(path: string, content: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId("/memory/file", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ path, content }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compact memory content using AI to distill it down to the most important insights.
|
|
* Reads one memory file, compacts it via AI, and writes the result back.
|
|
*
|
|
* Backwards-compatible call patterns:
|
|
* - compactMemory(projectId?)
|
|
* - compactMemory(path, projectId?)
|
|
*
|
|
* @param pathOrProjectId - Memory file path or legacy projectId-only argument
|
|
* @param projectId - Optional project ID for multi-project support
|
|
* @returns Promise resolving to the compacted memory content
|
|
*/
|
|
export function compactMemory(
|
|
pathOrProjectId?: string,
|
|
projectId?: string,
|
|
): Promise<{ path?: string; content: string }> {
|
|
let path: string | undefined;
|
|
let effectiveProjectId = projectId;
|
|
|
|
if (projectId !== undefined) {
|
|
path = pathOrProjectId;
|
|
} else if (typeof pathOrProjectId === "string" && pathOrProjectId.trim().length > 0) {
|
|
const trimmed = pathOrProjectId.trim();
|
|
const looksLikeMemoryPath = trimmed.includes("/") || trimmed.endsWith(".md") || trimmed.startsWith(".");
|
|
if (looksLikeMemoryPath) {
|
|
path = trimmed;
|
|
} else {
|
|
effectiveProjectId = trimmed;
|
|
}
|
|
}
|
|
|
|
return api<{ path?: string; content: string }>(withProjectId("/memory/compact", effectiveProjectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(path ? { path } : {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger manual memory dream processing.
|
|
* Synthesizes daily notes into dreams and promotes durable lessons to long-term memory.
|
|
*
|
|
* @param projectId - Optional project ID for multi-project support
|
|
* @returns Promise resolving to dream processing result
|
|
*/
|
|
export function triggerMemoryDreams(projectId?: string): Promise<{
|
|
success: boolean;
|
|
summary?: string;
|
|
dreamsWritten?: boolean;
|
|
longTermUpdatesWritten?: boolean;
|
|
error?: string;
|
|
}> {
|
|
return api(withProjectId("/memory/dream", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Memory audit report type (mirrors @fusion/core MemoryAuditReport) */
|
|
export interface MemoryAuditReport {
|
|
generatedAt: string;
|
|
workingMemory: {
|
|
exists: boolean;
|
|
size: number;
|
|
sectionCount: number;
|
|
lastModified?: string;
|
|
};
|
|
insightsMemory: {
|
|
exists: boolean;
|
|
size: number;
|
|
insightCount: number;
|
|
categories: Record<string, number>;
|
|
lastUpdated?: string;
|
|
};
|
|
extraction: {
|
|
runAt: string;
|
|
success: boolean;
|
|
insightCount: number;
|
|
duplicateCount: number;
|
|
skippedCount: number;
|
|
summary: string;
|
|
error?: string;
|
|
};
|
|
pruning: {
|
|
applied: boolean;
|
|
reason: string;
|
|
sizeDelta: number;
|
|
originalSize: number;
|
|
newSize: number;
|
|
};
|
|
checks: Array<{
|
|
id: string;
|
|
name: string;
|
|
passed: boolean;
|
|
details: string;
|
|
}>;
|
|
health: "healthy" | "warning" | "issues";
|
|
}
|
|
|
|
/**
|
|
* Fetch memory insights content.
|
|
* Returns { content: string | null, exists: boolean }.
|
|
* content is null when no insights file exists yet.
|
|
*/
|
|
export function fetchMemoryInsights(projectId?: string): Promise<{ content: string | null; exists: boolean }> {
|
|
return api<{ content: string | null; exists: boolean }>(withProjectId("/memory/insights", projectId));
|
|
}
|
|
|
|
/**
|
|
* Save memory insights content.
|
|
* The insights file stores parsed long-term memory grouped by category.
|
|
*/
|
|
export function saveMemoryInsights(content: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId("/memory/insights", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger AI-powered insight extraction from working memory.
|
|
* Reads working memory, generates insights via AI, merges/prunes existing insights,
|
|
* and generates an audit report.
|
|
*
|
|
* Returns: { success: boolean, summary: string, insightCount: number, pruned: boolean }
|
|
*/
|
|
export function triggerInsightExtraction(projectId?: string): Promise<{ success: boolean; summary: string; insightCount: number; pruned: boolean }> {
|
|
return api<{ success: boolean; summary: string; insightCount: number; pruned: boolean }>(withProjectId("/memory/extract", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch memory audit report.
|
|
* The audit checks working memory and insights memory state, extraction history,
|
|
* and generates health recommendations.
|
|
*/
|
|
export function fetchMemoryAudit(projectId?: string): Promise<MemoryAuditReport> {
|
|
return api<MemoryAuditReport>(withProjectId("/memory/audit", projectId));
|
|
}
|
|
|
|
/**
|
|
* Fetch quick memory stats (lightweight, no AI).
|
|
* Useful for dashboard displays showing memory size and insight counts.
|
|
*
|
|
* Returns: { workingMemorySize: number, insightsSize: number, insightsExists: boolean }
|
|
*/
|
|
export function fetchMemoryStats(projectId?: string): Promise<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean }> {
|
|
return api<{ workingMemorySize: number; insightsSize: number; insightsExists: boolean }>(withProjectId("/memory/stats", projectId));
|
|
}
|
|
|
|
/**
|
|
* Memory backend capabilities returned by the backend status API.
|
|
*/
|
|
export interface MemoryBackendCapabilities {
|
|
readable: boolean;
|
|
writable: boolean;
|
|
supportsAtomicWrite: boolean;
|
|
hasConflictResolution: boolean;
|
|
persistent: boolean;
|
|
}
|
|
|
|
/**
|
|
* Memory backend status response from GET /api/memory/backend
|
|
*/
|
|
export interface MemoryBackendStatus {
|
|
/** The effective backend type after runtime resolution */
|
|
currentBackend: string;
|
|
/** Capabilities of the effective backend */
|
|
capabilities: MemoryBackendCapabilities;
|
|
/** List of registered backend types available */
|
|
availableBackends: string[];
|
|
/** Whether the qmd CLI is available on PATH */
|
|
qmdAvailable?: boolean;
|
|
/** Suggested install command when qmd is unavailable */
|
|
qmdInstallCommand?: string;
|
|
}
|
|
|
|
export interface MemorySearchResult {
|
|
path: string;
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
snippet: string;
|
|
score: number;
|
|
backend: string;
|
|
}
|
|
|
|
export interface MemoryRetrievalTestResult {
|
|
query: string;
|
|
qmdAvailable: boolean;
|
|
usedFallback: boolean;
|
|
qmdInstallCommand: string;
|
|
results: MemorySearchResult[];
|
|
}
|
|
|
|
export interface QmdInstallResult {
|
|
success: boolean;
|
|
qmdAvailable: boolean;
|
|
qmdInstallCommand: string;
|
|
}
|
|
|
|
/**
|
|
* Fetch the current memory backend status and capabilities.
|
|
* Use this to determine which backend is active and what operations it supports.
|
|
*/
|
|
export function fetchMemoryBackendStatus(projectId?: string): Promise<MemoryBackendStatus> {
|
|
return api<MemoryBackendStatus>(withProjectId("/memory/backend", projectId));
|
|
}
|
|
|
|
export function installQmd(projectId?: string): Promise<QmdInstallResult> {
|
|
return api<QmdInstallResult>(withProjectId("/memory/install-qmd", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function testMemoryRetrieval(query: string, projectId?: string): Promise<MemoryRetrievalTestResult> {
|
|
return api<MemoryRetrievalTestResult>(withProjectId("/memory/test", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
}
|
|
|
|
/** Fetch global (user-level) settings from ~/.fusion/settings.json */
|
|
export function fetchGlobalSettings(): Promise<GlobalSettings> {
|
|
return api<GlobalSettings>("/settings/global");
|
|
}
|
|
|
|
/** Update global (user-level) settings. These persist across all fn 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 interface PiExtensionEntry {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
source: "fusion-global" | "pi-global" | "fusion-project" | "pi-project" | "package";
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface PiExtensionSettings {
|
|
extensions: PiExtensionEntry[];
|
|
disabledIds: string[];
|
|
settingsPath: string;
|
|
}
|
|
|
|
export function fetchPiExtensions(projectId?: string): Promise<PiExtensionSettings> {
|
|
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId));
|
|
}
|
|
|
|
export function updatePiExtensions(disabledIds: string[], projectId?: string): Promise<PiExtensionSettings> {
|
|
return api<PiExtensionSettings>(withProjectId("/settings/pi-extensions", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ disabledIds }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Test a notification provider by sending a test notification.
|
|
* Supports "ntfy" and "webhook" provider IDs.
|
|
*/
|
|
export function testNotification(providerId: string, config?: Record<string, unknown>, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId("/settings/test-notification", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ providerId, ...(config ?? {}) }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Backward-compatible ntfy test helper.
|
|
* Wraps testNotification() while preserving the legacy function signature.
|
|
*/
|
|
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
|
return testNotification("ntfy", config as Record<string, unknown> | undefined, projectId);
|
|
}
|
|
|
|
/** Pi extension settings from ~/.pi/agent/settings.json (global scope) */
|
|
export interface PiSettings {
|
|
packages: Array<string | { source: string; extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[] }>;
|
|
extensions: string[];
|
|
skills: string[];
|
|
prompts: string[];
|
|
themes: string[];
|
|
}
|
|
|
|
/** Fetch pi extension settings (global scope from ~/.pi/agent/settings.json) */
|
|
export function fetchPiSettings(): Promise<PiSettings> {
|
|
return api<PiSettings>("/pi-settings");
|
|
}
|
|
|
|
/** Update pi extension settings (partial update, global scope) */
|
|
export async function updatePiSettings(settings: Partial<PiSettings>): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/pi-settings", {
|
|
method: "PUT",
|
|
body: JSON.stringify(settings),
|
|
});
|
|
}
|
|
|
|
/** Install a new pi package source (adds to ~/.pi/agent/settings.json) */
|
|
export async function installPiPackage(source: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/pi-settings/packages", {
|
|
method: "POST",
|
|
body: JSON.stringify({ source }),
|
|
});
|
|
}
|
|
|
|
/** Reinstall Fusion's bundled pi package and ensure it remains in global Pi settings. */
|
|
export async function reinstallFusionPiPackage(projectId?: string): Promise<{ success: boolean; source: string }> {
|
|
return api<{ success: boolean; source: string }>(withProjectId("/pi-settings/reinstall-fusion", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
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",
|
|
headers: withTokenHeader(),
|
|
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,
|
|
options?: { limit?: number; offset?: number },
|
|
): Promise<AgentLogEntry[]> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) {
|
|
params.set("limit", String(options.limit));
|
|
}
|
|
if (options?.offset !== undefined) {
|
|
params.set("offset", String(options.offset));
|
|
}
|
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId));
|
|
}
|
|
|
|
/**
|
|
* Fetch agent logs with pagination metadata.
|
|
* Returns entries along with total count and hasMore flag from response headers.
|
|
*/
|
|
export async function fetchAgentLogsWithMeta(
|
|
taskId: string,
|
|
projectId?: string,
|
|
options?: { limit?: number; offset?: number },
|
|
): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) {
|
|
params.set("limit", String(options.limit));
|
|
}
|
|
if (options?.offset !== undefined) {
|
|
params.set("offset", String(options.offset));
|
|
}
|
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId);
|
|
|
|
const response = await fetch(buildApiUrl(url), {
|
|
headers: withTokenHeader(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" }));
|
|
throw new Error((data as { error?: string }).error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const entries = await response.json() as AgentLogEntry[];
|
|
|
|
// Read pagination headers
|
|
const total = response.headers.has("X-Total-Count")
|
|
? parseInt(response.headers.get("X-Total-Count")!, 10)
|
|
: entries.length;
|
|
const hasMore = response.headers.has("X-Has-More")
|
|
? response.headers.get("X-Has-More") === "true"
|
|
: false;
|
|
|
|
return { entries, total, hasMore };
|
|
}
|
|
|
|
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",
|
|
});
|
|
}
|
|
|
|
// ── Task Document API Functions ──────────────────────────────────────────────
|
|
|
|
export function fetchTaskDocuments(taskId: string, projectId?: string): Promise<TaskDocument[]> {
|
|
return api<TaskDocument[]>(withProjectId(`/tasks/${taskId}/documents`, projectId));
|
|
}
|
|
|
|
export function fetchTaskDocument(taskId: string, key: string, projectId?: string): Promise<TaskDocument> {
|
|
return api<TaskDocument>(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId));
|
|
}
|
|
|
|
export function fetchTaskDocumentRevisions(taskId: string, key: string, projectId?: string): Promise<TaskDocumentRevision[]> {
|
|
return api<TaskDocumentRevision[]>(withProjectId(`/tasks/${taskId}/documents/${key}/revisions`, projectId));
|
|
}
|
|
|
|
export interface FetchAllDocumentsOptions {
|
|
q?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface MarkdownFileEntry {
|
|
path: string;
|
|
name: string;
|
|
size: number;
|
|
mtime: string;
|
|
}
|
|
|
|
export interface MarkdownFileListResponse {
|
|
files: MarkdownFileEntry[];
|
|
}
|
|
|
|
export async function fetchAllDocuments(
|
|
options?: FetchAllDocumentsOptions,
|
|
projectId?: string,
|
|
): Promise<TaskDocumentWithTask[]> {
|
|
const params = new URLSearchParams();
|
|
if (options?.q) params.set("q", options.q);
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
|
const queryString = params.toString();
|
|
const path = `/documents${queryString ? `?${queryString}` : ""}`;
|
|
return api<TaskDocumentWithTask[]>(withProjectId(path, projectId));
|
|
}
|
|
|
|
export interface FetchProjectMarkdownFilesOptions {
|
|
showHidden?: boolean;
|
|
}
|
|
|
|
export function fetchProjectMarkdownFiles(
|
|
projectId?: string,
|
|
options?: FetchProjectMarkdownFilesOptions,
|
|
): Promise<MarkdownFileListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.showHidden) {
|
|
params.set("showHidden", "1");
|
|
}
|
|
|
|
const query = params.toString();
|
|
const path = `/files/markdown-list${query ? `?${query}` : ""}`;
|
|
|
|
return api<MarkdownFileListResponse>(withProjectId(path, projectId));
|
|
}
|
|
|
|
export function putTaskDocument(
|
|
taskId: string,
|
|
key: string,
|
|
content: string,
|
|
opts?: { author?: string; metadata?: Record<string, unknown> },
|
|
projectId?: string,
|
|
): Promise<TaskDocument> {
|
|
return api<TaskDocument>(withProjectId(`/tasks/${taskId}/documents/${key}`, projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
content,
|
|
author: opts?.author,
|
|
metadata: opts?.metadata,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function deleteTaskDocument(taskId: string, key: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/tasks/${taskId}/documents/${key}`, 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 rebuildTaskSpec(id: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${id}/spec/rebuild`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
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[];
|
|
defaultProvider?: string;
|
|
defaultModelId?: string;
|
|
resolvedPlanningProvider?: string;
|
|
resolvedPlanningModelId?: 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;
|
|
/** True when the server currently has an active OAuth login flow for this provider. */
|
|
loginInProgress?: boolean;
|
|
/**
|
|
* How this provider authenticates / is activated.
|
|
* - "oauth": OAuth flow (user clicks Login → redirect)
|
|
* - "api_key": API key stored locally
|
|
* - "cli": a locally-installed CLI binary is the backing transport
|
|
* (e.g. the synthetic `claude-cli` provider). Cards should render a
|
|
* one-click Enable/Disable + Test button rather than login/key inputs.
|
|
*/
|
|
type?: "oauth" | "api_key" | "cli";
|
|
/** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */
|
|
keyHint?: string;
|
|
}
|
|
|
|
export interface ManualOAuthCodeInfo {
|
|
prompt: string;
|
|
placeholder?: string;
|
|
helpText?: string;
|
|
}
|
|
|
|
/**
|
|
* Snapshot of the Claude-CLI-via-pi health state. Powers the
|
|
* "Anthropic — via Claude CLI" provider card.
|
|
*/
|
|
export interface ClaudeCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface DroidCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface CursorCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
extension: null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface LlamaCppStatus {
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
server: {
|
|
available: boolean;
|
|
url: string;
|
|
hasApiKey: boolean;
|
|
reason?: string;
|
|
};
|
|
}
|
|
|
|
/** Probe the local Claude CLI binary + setting + extension state. */
|
|
export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
|
|
return api<ClaudeCliStatus>("/providers/claude-cli/status");
|
|
}
|
|
|
|
/**
|
|
* Status snapshot for the Fusion CLI binary (`fn` / `fusion`). Used by
|
|
* Settings → General → CLI Binary and the first-launch banner.
|
|
*/
|
|
export interface FnBinaryStatus {
|
|
binary: {
|
|
installed: boolean;
|
|
binary?: "fn" | "fusion";
|
|
path?: string;
|
|
version?: string;
|
|
invocation: string;
|
|
};
|
|
expectedVersion: string;
|
|
state: "installed" | "missing" | "version-mismatch" | "skipped";
|
|
install: { npm: string; curl: string; package: string };
|
|
}
|
|
|
|
export interface FnBinaryInstallResult {
|
|
success: boolean;
|
|
exitCode: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
command: string;
|
|
durationMs: number;
|
|
permissionsHint?: string;
|
|
}
|
|
|
|
export interface FnBinaryInstallResponse extends FnBinaryStatus {
|
|
installResult: FnBinaryInstallResult;
|
|
}
|
|
|
|
/** Read CLI binary install state. */
|
|
export function fetchFnBinaryStatus(): Promise<FnBinaryStatus> {
|
|
return api<FnBinaryStatus>("/system/fn-binary/status");
|
|
}
|
|
|
|
/** Trigger `npm install -g runfusion.ai`. Returns install log + new status. */
|
|
export function installFnBinary(): Promise<FnBinaryInstallResponse> {
|
|
return api<FnBinaryInstallResponse>("/system/fn-binary/install", { method: "POST" });
|
|
}
|
|
|
|
/** Probe the local Droid CLI binary + setting + extension state. */
|
|
export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
|
|
return api<DroidCliStatus>("/providers/droid-cli/status");
|
|
}
|
|
|
|
export function fetchCursorCliStatus(): Promise<CursorCliStatus> {
|
|
return api<CursorCliStatus>("/providers/cursor-cli/status");
|
|
}
|
|
|
|
/** Probe llama.cpp server + setting + extension state. */
|
|
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
|
|
return api<LlamaCppStatus>("/providers/llama-cpp/status");
|
|
}
|
|
|
|
// --- Runtime Provider Status Types ---
|
|
|
|
export interface RuntimeBinaryStatus {
|
|
available: boolean;
|
|
binaryPath?: string;
|
|
version?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
}
|
|
|
|
export interface PaperclipConnectionStatus {
|
|
available: boolean;
|
|
apiUrl: string;
|
|
identity?: {
|
|
agentId: string;
|
|
agentName: string;
|
|
role?: string;
|
|
companyId: string;
|
|
companyName?: string;
|
|
};
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
}
|
|
|
|
export interface HermesProviderStatus {
|
|
binary: RuntimeBinaryStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface OpenClawProviderStatus {
|
|
binary: RuntimeBinaryStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface PaperclipProviderStatus {
|
|
connection: PaperclipConnectionStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
/** Probe the local Hermes binary. */
|
|
export async function fetchHermesStatus(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<HermesProviderStatus> {
|
|
const qs = opts?.binaryPath
|
|
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
|
: "";
|
|
return api<HermesProviderStatus>(`/providers/hermes/status${qs}`);
|
|
}
|
|
|
|
export interface HermesProfileSummary {
|
|
name: string;
|
|
model?: string;
|
|
gateway?: string;
|
|
alias?: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
/** List Hermes profiles from `hermes profile list`. Returns empty array on error. */
|
|
export async function fetchHermesProfiles(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<HermesProfileSummary[]> {
|
|
const qs = opts?.binaryPath ? `?binaryPath=${encodeURIComponent(opts.binaryPath)}` : "";
|
|
const r = await api<{ profiles: HermesProfileSummary[]; error?: string }>(
|
|
`/providers/hermes/profiles${qs}`,
|
|
);
|
|
return r.profiles ?? [];
|
|
}
|
|
|
|
/** Probe the local OpenClaw binary. */
|
|
export async function fetchOpenClawStatus(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<OpenClawProviderStatus> {
|
|
const qs = opts?.binaryPath
|
|
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
|
: "";
|
|
return api<OpenClawProviderStatus>(`/providers/openclaw/status${qs}`);
|
|
}
|
|
|
|
/** Probe the Paperclip API connection. */
|
|
export async function fetchPaperclipStatus(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
}): Promise<PaperclipProviderStatus> {
|
|
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
return api<PaperclipProviderStatus>(
|
|
`/providers/paperclip/status?${params.toString()}`,
|
|
);
|
|
}
|
|
|
|
export interface PaperclipCompanySummary {
|
|
id: string;
|
|
name: string;
|
|
urlKey?: string;
|
|
}
|
|
|
|
export interface PaperclipAgentSummary {
|
|
id: string;
|
|
name: string;
|
|
role?: string;
|
|
companyId: string;
|
|
status?: string;
|
|
isCurrent?: boolean;
|
|
}
|
|
|
|
export interface PaperclipCliDiscoverySuccess {
|
|
ok: true;
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
configPath: string;
|
|
deploymentMode?: string;
|
|
}
|
|
|
|
export interface PaperclipCliDiscoveryFailure {
|
|
ok: false;
|
|
reason: string;
|
|
configPath?: string;
|
|
}
|
|
|
|
export type PaperclipCliDiscoveryResult =
|
|
| PaperclipCliDiscoverySuccess
|
|
| PaperclipCliDiscoveryFailure;
|
|
|
|
/** List Paperclip companies visible to the bearer. Empty array on failure. */
|
|
export async function fetchPaperclipCompanies(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
}): Promise<PaperclipCompanySummary[]> {
|
|
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
const r = await api<{ companies: PaperclipCompanySummary[] }>(
|
|
`/providers/paperclip/companies?${params.toString()}`,
|
|
);
|
|
return r.companies ?? [];
|
|
}
|
|
|
|
/** List agents in a Paperclip company. Empty array on failure. */
|
|
export async function fetchPaperclipAgents(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
companyId: string;
|
|
}): Promise<PaperclipAgentSummary[]> {
|
|
const params = new URLSearchParams({
|
|
apiUrl: opts.apiUrl,
|
|
companyId: opts.companyId,
|
|
});
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
const r = await api<{ agents: PaperclipAgentSummary[] }>(
|
|
`/providers/paperclip/agents?${params.toString()}`,
|
|
);
|
|
return r.agents ?? [];
|
|
}
|
|
|
|
export interface PaperclipMintKeyRequest {
|
|
cliBinaryPath?: string;
|
|
agentRef: string;
|
|
/** Required by paperclipai agent local-cli (`-C/--company-id`). */
|
|
companyId: string;
|
|
keyName?: string;
|
|
configPath?: string;
|
|
dataDir?: string;
|
|
}
|
|
export type PaperclipMintKeyResult =
|
|
| { ok: true; key: { apiKey: string; apiBase?: string; agentId?: string; companyId?: string } }
|
|
| { ok: false; reason: string };
|
|
|
|
/**
|
|
* Mints a Paperclip agent API key via the local `paperclipai` CLI.
|
|
* Always resolves (never rejects); on failure the result has `ok: false`.
|
|
*/
|
|
export async function mintPaperclipApiKey(
|
|
body: PaperclipMintKeyRequest,
|
|
): Promise<PaperclipMintKeyResult> {
|
|
return api<PaperclipMintKeyResult>(`/providers/paperclip/cli-mint-key`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Probe Paperclip via the local `paperclipai` CLI (Local CLI tab). Carries the
|
|
* user's onboarded CLI context (profile / api-base / api-key) instead of having
|
|
* the dashboard server make the HTTP call directly.
|
|
*/
|
|
export async function fetchPaperclipCliStatus(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
}): Promise<PaperclipProviderStatus> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
return api<PaperclipProviderStatus>(
|
|
`/providers/paperclip/cli-status${qs ? `?${qs}` : ""}`,
|
|
);
|
|
}
|
|
|
|
/** List companies via `paperclipai company list --json`. Empty array on failure. */
|
|
export async function fetchPaperclipCliCompanies(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
}): Promise<PaperclipCompanySummary[]> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
const r = await api<{ companies: PaperclipCompanySummary[] }>(
|
|
`/providers/paperclip/cli-companies${qs ? `?${qs}` : ""}`,
|
|
);
|
|
return r.companies ?? [];
|
|
}
|
|
|
|
/** List agents in a company via `paperclipai agent list -C <id> --json`. */
|
|
export async function fetchPaperclipCliAgents(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
companyId: string;
|
|
}): Promise<PaperclipAgentSummary[]> {
|
|
const params = new URLSearchParams({ companyId: opts.companyId });
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const r = await api<{ agents: PaperclipAgentSummary[] }>(
|
|
`/providers/paperclip/cli-agents?${params.toString()}`,
|
|
);
|
|
return r.agents ?? [];
|
|
}
|
|
|
|
/** Read the local paperclipai config to discover apiUrl + deploymentMode. */
|
|
export async function fetchPaperclipCliDiscovery(opts: {
|
|
cliConfigPath?: string;
|
|
} = {}): Promise<PaperclipCliDiscoveryResult> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
return api<PaperclipCliDiscoveryResult>(
|
|
`/providers/paperclip/cli-discovery${qs ? `?${qs}` : ""}`,
|
|
);
|
|
}
|
|
|
|
/** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */
|
|
export function setClaudeCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/claude-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
/** Enable or disable the Droid CLI provider. Refuses enable if binary is missing. */
|
|
export function setDroidCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/droid-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export function setCursorCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/cursor-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
/** Enable or disable the llama.cpp provider. */
|
|
export function setLlamaCppEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/llama-cpp", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export interface CustomProvider {
|
|
id: string;
|
|
name: string;
|
|
apiType: "openai-compatible" | "anthropic-compatible";
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
models?: { id: string; name: string }[];
|
|
}
|
|
|
|
export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & { providers: CustomProviderConfig[] }> {
|
|
const providers = await api<CustomProvider[]>("/custom-providers");
|
|
const legacyProviders = providers.map((provider) => ({
|
|
id: provider.id,
|
|
name: provider.name,
|
|
baseUrl: provider.baseUrl,
|
|
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
|
apiKey: provider.apiKey,
|
|
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
|
} satisfies CustomProviderConfig));
|
|
return Object.assign(legacyProviders, { providers: legacyProviders });
|
|
}
|
|
|
|
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
|
|
return api<CustomProvider>("/custom-providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(provider),
|
|
});
|
|
}
|
|
|
|
export function updateCustomProvider(
|
|
id: string,
|
|
updates: Partial<Omit<CustomProvider, "id">> | CustomProviderConfig,
|
|
): Promise<CustomProvider> {
|
|
const legacy = updates as Partial<CustomProviderConfig>;
|
|
const normalized: Partial<Omit<CustomProvider, "id">> = {
|
|
...(typeof legacy.name === "string" ? { name: legacy.name } : {}),
|
|
...(typeof legacy.baseUrl === "string" ? { baseUrl: legacy.baseUrl } : {}),
|
|
...(typeof legacy.apiKey === "string" ? { apiKey: legacy.apiKey } : {}),
|
|
...(Array.isArray(legacy.models)
|
|
? {
|
|
models: legacy.models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name ?? model.id,
|
|
})),
|
|
}
|
|
: {}),
|
|
...(legacy.api
|
|
? {
|
|
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
|
|
}
|
|
: {}),
|
|
...("apiType" in (updates as Record<string, unknown>)
|
|
? { apiType: (updates as Partial<Omit<CustomProvider, "id">>).apiType }
|
|
: {}),
|
|
};
|
|
|
|
return api<CustomProvider>(`/custom-providers/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(normalized),
|
|
});
|
|
}
|
|
|
|
export function deleteCustomProvider(id: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(`/custom-providers/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// Backward-compatibility exports for existing UI callers; will be removed when
|
|
// custom-provider UI migrates to the new core CustomProvider contract.
|
|
export interface CustomProviderModelInput {
|
|
id: string;
|
|
name?: string;
|
|
reasoning?: boolean;
|
|
contextWindow?: number;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export interface CustomProviderConfig {
|
|
id: string;
|
|
name?: string;
|
|
baseUrl: string;
|
|
api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
|
apiKey?: string;
|
|
models: CustomProviderModelInput[];
|
|
}
|
|
|
|
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
|
|
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible";
|
|
return addCustomProvider({
|
|
name: config.name?.trim() || config.id,
|
|
apiType,
|
|
baseUrl: config.baseUrl,
|
|
apiKey: config.apiKey,
|
|
models: config.models?.map((model) => ({
|
|
id: model.id,
|
|
name: model.name ?? model.id,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/** Fetch authentication status for all OAuth providers */
|
|
export function fetchAuthStatus(): Promise<{
|
|
providers: AuthProvider[];
|
|
ghCli?: { available: boolean; authenticated: boolean };
|
|
}> {
|
|
return api<{
|
|
providers: AuthProvider[];
|
|
ghCli?: { available: boolean; authenticated: boolean };
|
|
}>("/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;
|
|
manualCode?: ManualOAuthCodeInfo;
|
|
}> {
|
|
return api<{
|
|
url: string;
|
|
instructions?: string;
|
|
manualCode?: ManualOAuthCodeInfo;
|
|
}>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, origin: window.location.origin }),
|
|
});
|
|
}
|
|
|
|
/** Submit a pasted OAuth callback URL or authorization code for an active login. */
|
|
export function submitProviderManualCode(provider: string, code: string): Promise<{ success: boolean; submitted: boolean }> {
|
|
return api<{ success: boolean; submitted: boolean }>("/auth/manual-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, code }),
|
|
});
|
|
}
|
|
|
|
/** 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 }),
|
|
});
|
|
}
|
|
|
|
/** Cancel an in-progress OAuth login attempt for a provider. */
|
|
export function cancelProviderLogin(provider: string): Promise<{ success: boolean; cancelled: boolean }> {
|
|
return api<{ success: boolean; cancelled: boolean }>("/auth/cancel", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider }),
|
|
});
|
|
}
|
|
|
|
/** Save an API key for an API-key-backed provider. */
|
|
export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/auth/api-key", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, apiKey }),
|
|
});
|
|
}
|
|
|
|
/** Remove an API key for an API-key-backed provider. */
|
|
export function clearApiKey(provider: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/auth/api-key", {
|
|
method: "DELETE",
|
|
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 fn task */
|
|
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId("/github/issues/import", projectId), {
|
|
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 fn tasks with throttling */
|
|
export function apiBatchImportGitHubIssues(
|
|
owner: string,
|
|
repo: string,
|
|
issueNumbers: number[],
|
|
delayMs?: number,
|
|
projectId?: string
|
|
): Promise<{ results: BatchImportResult[] }> {
|
|
return api<{ results: BatchImportResult[] }>(withProjectId("/github/issues/batch-import", projectId), {
|
|
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 fn review task */
|
|
export function apiImportGitHubPull(owner: string, repo: string, prNumber: number, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId("/github/pulls/import", projectId), {
|
|
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(projectId?: string): Promise<GitRemote[]> {
|
|
return api<GitRemote[]>(withProjectId("/git/remotes", projectId));
|
|
}
|
|
|
|
/** 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(projectId?: string): Promise<GitRemoteDetailed[]> {
|
|
return api<GitRemoteDetailed[]>(withProjectId("/git/remotes/detailed", projectId));
|
|
}
|
|
|
|
/** Add a new git remote */
|
|
export function addGitRemote(name: string, url: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId("/git/remotes", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ name, url }),
|
|
});
|
|
}
|
|
|
|
/** Remove a git remote */
|
|
export function removeGitRemote(name: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Rename a git remote */
|
|
export function renameGitRemote(name: string, newName: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ newName }),
|
|
});
|
|
}
|
|
|
|
/** Update the URL for a git remote */
|
|
export function updateGitRemoteUrl(name: string, url: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), {
|
|
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, projectId?: string): Promise<TerminalExecResponse> {
|
|
return api<TerminalExecResponse>(withProjectId("/terminal/exec", projectId), {
|
|
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,
|
|
projectId?: string
|
|
): Promise<PtyTerminalSession> {
|
|
return api<PtyTerminalSession>(withProjectId("/terminal/sessions", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ cwd, cols, rows }),
|
|
});
|
|
}
|
|
|
|
/** Kill a PTY terminal session */
|
|
export function killPtyTerminalSession(sessionId: string, projectId?: string): Promise<{ killed: boolean }> {
|
|
return api<{ killed: boolean }>(withProjectId(`/terminal/sessions/${encodeURIComponent(sessionId)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** List active PTY terminal sessions */
|
|
export function listTerminalSessions(projectId?: string): Promise<PtyTerminalSessionInfo[]> {
|
|
return api<PtyTerminalSessionInfo[]>(withProjectId("/terminal/sessions", projectId));
|
|
}
|
|
|
|
// --- 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;
|
|
body?: 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(projectId?: string): Promise<GitStatus> {
|
|
return api<GitStatus>(withProjectId("/git/status", projectId));
|
|
}
|
|
|
|
/** Fetch recent commits */
|
|
export function fetchGitCommits(limit?: number, projectId?: string): Promise<GitCommit[]> {
|
|
const query = limit ? `?limit=${limit}` : "";
|
|
return api<GitCommit[]>(withProjectId(`/git/commits${query}`, projectId));
|
|
}
|
|
|
|
/** Fetch diff for a specific commit */
|
|
export function fetchCommitDiff(hash: string, projectId?: string): Promise<{ stat: string; patch: string }> {
|
|
return api<{ stat: string; patch: string }>(withProjectId(`/git/commits/${hash}/diff`, projectId));
|
|
}
|
|
|
|
/** Fetch local commits ahead of the upstream tracking branch (commits to push) */
|
|
export function fetchAheadCommits(projectId?: string): Promise<GitCommit[]> {
|
|
return api<GitCommit[]>(withProjectId("/git/commits/ahead", projectId));
|
|
}
|
|
|
|
/** Fetch recent commits for a specific remote */
|
|
export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string): Promise<GitCommit[]> {
|
|
const params = new URLSearchParams();
|
|
if (ref) params.set("ref", ref);
|
|
if (limit) params.set("limit", String(limit));
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<GitCommit[]>(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId));
|
|
}
|
|
|
|
/** Fetch all local branches */
|
|
export function fetchGitBranches(projectId?: string): Promise<GitBranch[]> {
|
|
return api<GitBranch[]>(withProjectId("/git/branches", projectId));
|
|
}
|
|
|
|
/** Fetch recent commits for a specific branch */
|
|
export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise<GitCommit[]> {
|
|
const query = limit ? `?limit=${limit}` : "";
|
|
return api<GitCommit[]>(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId));
|
|
}
|
|
|
|
/** Fetch all worktrees */
|
|
export function fetchGitWorktrees(projectId?: string): Promise<GitWorktree[]> {
|
|
return api<GitWorktree[]>(withProjectId("/git/worktrees", projectId));
|
|
}
|
|
|
|
/** Create a new branch */
|
|
export function createBranch(name: string, base?: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId("/git/branches", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ name, base }),
|
|
});
|
|
}
|
|
|
|
/** Checkout an existing branch */
|
|
export function checkoutBranch(name: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Delete a branch */
|
|
export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise<void> {
|
|
const query = force ? "?force=true" : "";
|
|
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Fetch from remote */
|
|
export function fetchRemote(remote?: string, projectId?: string): Promise<GitFetchResult> {
|
|
return api<GitFetchResult>(withProjectId("/git/fetch", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ remote }),
|
|
});
|
|
}
|
|
|
|
/** Pull current branch */
|
|
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
|
|
export function pullBranch(projectId?: string): Promise<GitPullResult>;
|
|
export function pullBranch(
|
|
optionsOrProjectId?: { rebase?: boolean } | string,
|
|
projectId?: string,
|
|
): Promise<GitPullResult> {
|
|
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
|
|
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
|
|
|
|
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ rebase: options?.rebase ?? false }),
|
|
});
|
|
}
|
|
|
|
/** Push current branch */
|
|
export function pushBranch(projectId?: string): Promise<GitPushResult> {
|
|
return api<GitPushResult>(withProjectId("/git/push", projectId), {
|
|
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(projectId?: string): Promise<GitStash[]> {
|
|
return api<GitStash[]>(withProjectId("/git/stashes", projectId));
|
|
}
|
|
|
|
/** Create a new stash */
|
|
export function createStash(message?: string, projectId?: string): Promise<{ message: string }> {
|
|
return api<{ message: string }>(withProjectId("/git/stashes", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
}
|
|
|
|
/** Apply a stash entry */
|
|
export function applyStash(index: number, drop?: boolean, projectId?: string): Promise<{ message: string }> {
|
|
return api<{ message: string }>(withProjectId(`/git/stashes/${index}/apply`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ drop }),
|
|
});
|
|
}
|
|
|
|
/** Drop a stash entry */
|
|
export function dropStash(index: number, projectId?: string): Promise<{ message: string }> {
|
|
return api<{ message: string }>(withProjectId(`/git/stashes/${index}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Fetch stash diff (stat + patch) */
|
|
export function fetchStashDiff(index: number, projectId?: string): Promise<{ stat: string; patch: string }> {
|
|
return api<{ stat: string; patch: string }>(withProjectId(`/git/stashes/${index}/diff`, projectId));
|
|
}
|
|
|
|
/** Fetch unstaged diff (working directory changes) */
|
|
export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> {
|
|
return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId));
|
|
}
|
|
|
|
/** Fetch diff for a specific file in staged or unstaged mode */
|
|
export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> {
|
|
const params = new URLSearchParams();
|
|
params.set("path", path);
|
|
params.set("staged", String(staged));
|
|
return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId));
|
|
}
|
|
|
|
/** Fetch file changes (staged and unstaged) */
|
|
export function fetchFileChanges(projectId?: string): Promise<GitFileChange[]> {
|
|
return api<GitFileChange[]>(withProjectId("/git/changes", projectId));
|
|
}
|
|
|
|
/** Stage specific files */
|
|
export function stageFiles(files: string[], projectId?: string): Promise<{ staged: string[] }> {
|
|
return api<{ staged: string[] }>(withProjectId("/git/stage", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ files }),
|
|
});
|
|
}
|
|
|
|
/** Unstage specific files */
|
|
export function unstageFiles(files: string[], projectId?: string): Promise<{ unstaged: string[] }> {
|
|
return api<{ unstaged: string[] }>(withProjectId("/git/unstage", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ files }),
|
|
});
|
|
}
|
|
|
|
/** Create a commit */
|
|
export function createCommit(message: string, projectId?: string): Promise<{ hash: string; message: string }> {
|
|
return api<{ hash: string; message: string }>(withProjectId("/git/commit", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ message }),
|
|
});
|
|
}
|
|
|
|
/** Discard changes in working directory for specific files */
|
|
export function discardChanges(files: string[], projectId?: string): Promise<{ discarded: string[] }> {
|
|
return api<{ discarded: string[] }>(withProjectId("/git/discard", projectId), {
|
|
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, projectId?: string): Promise<FileListResponse> {
|
|
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
return api<FileListResponse>(withProjectId(`/tasks/${taskId}/files${query}`, projectId));
|
|
}
|
|
|
|
/** Fetch file content */
|
|
export function fetchFileContent(taskId: string, filePath: string, projectId?: string): Promise<FileContentResponse> {
|
|
return api<FileContentResponse>(withProjectId(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, projectId));
|
|
}
|
|
|
|
/** Save file content */
|
|
export function saveFileContent(taskId: string, filePath: string, content: string, projectId?: string): Promise<SaveFileResponse> {
|
|
return api<SaveFileResponse>(withProjectId(`/tasks/${taskId}/files/${encodeURIComponent(filePath)}`, projectId), {
|
|
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(projectId?: string): Promise<WorkspaceListResponse> {
|
|
return api<WorkspaceListResponse>(withProjectId("/workspaces", projectId));
|
|
}
|
|
|
|
/** List files in a workspace (project root or task worktree). */
|
|
export function fetchWorkspaceFileList(workspace: string, path?: string, projectId?: string): Promise<FileListResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (path) {
|
|
query.set("path", path);
|
|
}
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileListResponse>(`/files?${query.toString()}`);
|
|
}
|
|
|
|
/** Fetch file content from a workspace. */
|
|
export function fetchWorkspaceFileContent(workspace: string, filePath: string, projectId?: string): Promise<FileContentResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileContentResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`);
|
|
}
|
|
|
|
/** Save file content to a workspace. */
|
|
export function saveWorkspaceFileContent(workspace: string, filePath: string, content: string, projectId?: string): Promise<SaveFileResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<SaveFileResponse>(`/files/${encodeURIComponent(filePath)}?${query.toString()}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
}
|
|
|
|
/** File search result. */
|
|
export interface FileSearchResult {
|
|
files: Array<{ path: string; name: string }>;
|
|
}
|
|
|
|
/** Search for files matching a query in a workspace. */
|
|
export function searchFiles(query: string, workspace?: string, projectId?: string): Promise<FileSearchResult> {
|
|
const params = new URLSearchParams({ q: query });
|
|
if (workspace) {
|
|
params.set("workspace", workspace);
|
|
}
|
|
if (projectId) {
|
|
params.set("projectId", projectId);
|
|
}
|
|
return api<FileSearchResult>(`/files/search?${params.toString()}`);
|
|
}
|
|
|
|
// --- Workspace File Operations API (Copy, Move, Delete, Rename, Download) ---
|
|
|
|
/** File operation response for copy/move/delete/rename operations */
|
|
export interface FileOperationResponse {
|
|
success: true;
|
|
message?: string;
|
|
}
|
|
|
|
/** Copy a file or directory to a new location within a workspace. */
|
|
export function copyFile(workspace: string, filePath: string, destination: string, projectId?: string): Promise<FileOperationResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/copy?${query.toString()}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ destination }),
|
|
});
|
|
}
|
|
|
|
/** Move a file or directory to a new location within a workspace. */
|
|
export function moveFile(workspace: string, filePath: string, destination: string, projectId?: string): Promise<FileOperationResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/move?${query.toString()}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ destination }),
|
|
});
|
|
}
|
|
|
|
/** Delete a file or directory within a workspace. */
|
|
export function deleteFile(workspace: string, filePath: string, projectId?: string): Promise<FileOperationResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/delete?${query.toString()}`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Rename a file or directory within a workspace. */
|
|
export function renameFile(workspace: string, filePath: string, newName: string, projectId?: string): Promise<FileOperationResponse> {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return api<FileOperationResponse>(`/files/${encodeURIComponent(filePath)}/rename?${query.toString()}`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ newName }),
|
|
});
|
|
}
|
|
|
|
/** Get the download URL for a single file in a workspace. */
|
|
export function downloadFileUrl(workspace: string, filePath: string, projectId?: string): string {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return `/api/files/${encodeURIComponent(filePath)}/download?${query.toString()}`;
|
|
}
|
|
|
|
/** Get the download URL for a folder as ZIP in a workspace. */
|
|
export function downloadZipUrl(workspace: string, filePath: string, projectId?: string): string {
|
|
const query = new URLSearchParams({ workspace });
|
|
if (projectId) {
|
|
query.set("projectId", projectId);
|
|
}
|
|
return `/api/files/${encodeURIComponent(filePath)}/download-zip?${query.toString()}`;
|
|
}
|
|
|
|
// --- 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";
|
|
priority?: TaskPriority;
|
|
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> };
|
|
|
|
export interface AgentOnboardingSummary {
|
|
name: string;
|
|
role: AgentCapability | "custom";
|
|
instructionsText: string;
|
|
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
|
|
maxTurns: number;
|
|
title?: string;
|
|
icon?: string;
|
|
reportsTo?: string;
|
|
soul?: string;
|
|
memory?: string;
|
|
skills?: string[];
|
|
templateId?: string;
|
|
patternAgentId?: string;
|
|
rationale?: string;
|
|
model?: string;
|
|
/** Draft-only AI suggestion for eventual runtimeConfig.model selection. */
|
|
modelHint?: string;
|
|
/** Draft-only AI suggestion for eventual runtimeConfig.runtimeHint plugin runtime selection. */
|
|
runtimeHint?: string;
|
|
heartbeatProcedurePath?: string;
|
|
heartbeatIntervalMs?: number;
|
|
heartbeatEnabled?: boolean;
|
|
}
|
|
|
|
export type OnboardingMode = "create" | "edit";
|
|
|
|
export interface ExistingAgentOnboardingConfig {
|
|
name?: string;
|
|
role?: AgentCapability | "custom";
|
|
title?: string;
|
|
instructionsText?: string;
|
|
soul?: string;
|
|
memory?: string;
|
|
reportsTo?: string;
|
|
skills?: string[];
|
|
model?: string;
|
|
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
|
maxTurns?: number;
|
|
runtimeHint?: string;
|
|
heartbeatIntervalMs?: number;
|
|
heartbeatTimeoutMs?: number;
|
|
maxConcurrentRuns?: number;
|
|
messageResponseMode?: "immediate" | "on-heartbeat";
|
|
}
|
|
|
|
export type AgentOnboardingStreamEvent =
|
|
| { type: "thinking"; data: string }
|
|
| { type: "question"; data: PlanningQuestion }
|
|
| { type: "summary"; data: AgentOnboardingSummary }
|
|
| { 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,
|
|
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
|
|
): Promise<PlanningSession> {
|
|
return api<PlanningSession>(withProjectId("/planning/start", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
initialPlan,
|
|
planningDepth: planningOptions?.planningDepth,
|
|
customQuestionCount: planningOptions?.customQuestionCount,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function createPlanningDraft(
|
|
initialPlan: string,
|
|
projectId?: string,
|
|
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
|
|
): Promise<{ sessionId: string; title: string }> {
|
|
return api<{ sessionId: string; title: string }>(withProjectId("/planning/create-draft", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
initialPlan,
|
|
planningModelProvider: modelOverride?.planningModelProvider,
|
|
planningModelId: modelOverride?.planningModelId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Start a new planning session with AI streaming support */
|
|
export function startPlanningStreaming(
|
|
initialPlan: string,
|
|
projectId?: string,
|
|
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
|
|
planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number },
|
|
existingSessionId?: string,
|
|
): Promise<{ sessionId: string }> {
|
|
return api<{ sessionId: string }>(withProjectId("/planning/start-streaming", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
initialPlan,
|
|
planningModelProvider: modelOverride?.planningModelProvider,
|
|
planningModelId: modelOverride?.planningModelId,
|
|
planningDepth: planningOptions?.planningDepth,
|
|
customQuestionCount: planningOptions?.customQuestionCount,
|
|
...(existingSessionId ? { existingSessionId } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Submit a response to the current planning question */
|
|
export function respondToPlanning(
|
|
sessionId: string,
|
|
responses: Record<string, unknown>,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<PlanningSession> {
|
|
return api<PlanningSession>(withProjectId("/planning/respond", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, responses, tabId }),
|
|
});
|
|
}
|
|
|
|
/** Rewind a planning session to the previous answered question */
|
|
export function rewindPlanningSession(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }> {
|
|
return api<{ currentQuestion: PlanningQuestion; history: Array<{ question: PlanningQuestion; response: unknown; thinkingOutput?: string }> }>(
|
|
withProjectId(`/planning/${encodeURIComponent(sessionId)}/back`, projectId),
|
|
{
|
|
method: "POST",
|
|
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Retry a failed planning session turn */
|
|
export function retryPlanningSession(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ success: boolean; sessionId: string }> {
|
|
return api<{ success: boolean; sessionId: string }>(
|
|
withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId),
|
|
{
|
|
method: "POST",
|
|
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Stop in-flight planning generation for a session */
|
|
export function stopPlanningGeneration(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(
|
|
withProjectId(`/planning/${encodeURIComponent(sessionId)}/stop`, projectId),
|
|
{
|
|
method: "POST",
|
|
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Cancel an active planning session */
|
|
export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
|
return api<void>(withProjectId("/planning/cancel", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, tabId }),
|
|
});
|
|
}
|
|
|
|
export function startAgentOnboardingStreaming(
|
|
intent: string,
|
|
context: {
|
|
existingAgents: Array<{ id: string; name: string; role: string }>;
|
|
templates: Array<{ id: string; label: string; description?: string }>;
|
|
mode?: OnboardingMode;
|
|
existingAgentConfig?: ExistingAgentOnboardingConfig;
|
|
},
|
|
projectId?: string,
|
|
modelOverride?: { planningModelProvider?: string; planningModelId?: string },
|
|
): Promise<{ sessionId: string }> {
|
|
return api<{ sessionId: string }>(withProjectId("/agents/onboarding/start-streaming", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
intent,
|
|
context,
|
|
mode: context.mode,
|
|
existingAgentConfig: context.existingAgentConfig,
|
|
planningModelProvider: modelOverride?.planningModelProvider,
|
|
planningModelId: modelOverride?.planningModelId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function respondToAgentOnboarding(
|
|
sessionId: string,
|
|
responses: Record<string, unknown>,
|
|
projectId?: string,
|
|
): Promise<{ type: "question" | "complete"; data: PlanningQuestion | AgentOnboardingSummary }> {
|
|
return api(withProjectId("/agents/onboarding/respond", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, responses }),
|
|
});
|
|
}
|
|
|
|
export function retryAgentOnboardingSession(sessionId: string, projectId?: string): Promise<{ success: boolean; sessionId: string }> {
|
|
return api(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/retry`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function stopAgentOnboardingGeneration(sessionId: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/stop`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function cancelAgentOnboarding(sessionId: string, projectId?: string): Promise<void> {
|
|
return api(withProjectId("/agents/onboarding/cancel", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId }),
|
|
});
|
|
}
|
|
|
|
/** Create a task from a completed planning session */
|
|
export function createTaskFromPlanning(
|
|
sessionId: string,
|
|
summary?: PlanningSummary,
|
|
projectId?: string,
|
|
): Promise<Task> {
|
|
return api<Task>(withProjectId("/planning/create-task", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }),
|
|
});
|
|
}
|
|
|
|
/** Start subtask breakdown from a completed planning session */
|
|
export function startPlanningBreakdown(
|
|
sessionId: string,
|
|
summary?: PlanningSummary,
|
|
projectId?: string,
|
|
): Promise<{ sessionId: string; subtasks: SubtaskItem[] }> {
|
|
return api<{ sessionId: string; subtasks: SubtaskItem[] }>(
|
|
withProjectId("/planning/start-breakdown", projectId),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(summary ? { sessionId, summary } : { sessionId }),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Create multiple tasks from a completed planning session */
|
|
export function createTasksFromPlanning(
|
|
planningSessionId: string,
|
|
subtasks: Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L";
|
|
priority?: TaskPriority;
|
|
dependsOn: string[];
|
|
}>,
|
|
projectId?: string,
|
|
): Promise<{ tasks: Task[] }> {
|
|
return api<{ tasks: Task[] }>(withProjectId("/planning/create-tasks", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ planningSessionId, subtasks }),
|
|
});
|
|
}
|
|
|
|
|
|
type StreamConnectionState = "connected" | "reconnecting";
|
|
|
|
// Track every live createResilientEventSource instance so we can close their
|
|
// underlying EventSource sockets on page unload. Without this, Chrome holds
|
|
// the HTTP/1.1 sockets open in its keep-alive pool across refreshes, exhausts
|
|
// its 6-per-origin limit after ~3 refreshes, and every new fetch stalls —
|
|
// leaving the dashboard frozen on "Initializing...". sse-bus.ts has its own
|
|
// handler; this one covers the parallel EventSource path in api.ts.
|
|
const activeResilientEventSources = new Set<{ close: () => void }>();
|
|
if (typeof window !== "undefined") {
|
|
const closeAll = () => {
|
|
for (const handle of Array.from(activeResilientEventSources)) {
|
|
try { handle.close(); } catch { /* best effort */ }
|
|
}
|
|
};
|
|
window.addEventListener("pagehide", closeAll);
|
|
window.addEventListener("beforeunload", closeAll);
|
|
}
|
|
|
|
interface ResilientEventSourceOptions {
|
|
maxReconnectAttempts?: number;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
onFatalError?: (message: string) => void;
|
|
}
|
|
|
|
interface ResilientEventHandlers {
|
|
onOpen?: () => void;
|
|
onMessage?: (event: MessageEvent) => void;
|
|
events?: Record<string, (event: MessageEvent) => void>;
|
|
}
|
|
|
|
function appendLastEventId(url: string, lastEventId: number | null): string {
|
|
if (lastEventId === null || lastEventId <= 0) {
|
|
return url;
|
|
}
|
|
|
|
const separator = url.includes("?") ? "&" : "?";
|
|
return `${url}${separator}lastEventId=${encodeURIComponent(String(lastEventId))}`;
|
|
}
|
|
|
|
function createResilientEventSource(
|
|
url: string,
|
|
handlers: ResilientEventHandlers,
|
|
options: ResilientEventSourceOptions = {},
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
|
let eventSource: EventSource | null = null;
|
|
let closedByUser = false;
|
|
let reconnectAttempts = 0;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let lastSeenEventId: number | null = null;
|
|
let reconnectingNotified = false;
|
|
|
|
const shouldDispatch = (event: MessageEvent): boolean => {
|
|
const rawId = event.lastEventId;
|
|
if (!rawId) {
|
|
return true;
|
|
}
|
|
|
|
const parsedId = Number.parseInt(rawId, 10);
|
|
if (!Number.isFinite(parsedId)) {
|
|
return true;
|
|
}
|
|
|
|
if (lastSeenEventId !== null && parsedId <= lastSeenEventId) {
|
|
return false;
|
|
}
|
|
|
|
lastSeenEventId = parsedId;
|
|
return true;
|
|
};
|
|
|
|
const connect = (): void => {
|
|
if (closedByUser) return;
|
|
|
|
const nextUrl = appendLastEventId(url, lastSeenEventId);
|
|
// EventSource can't set headers — carry the bearer token via `fn_token=`.
|
|
const source = new EventSource(appendTokenQuery(nextUrl));
|
|
eventSource = source;
|
|
|
|
source.onopen = () => {
|
|
reconnectAttempts = 0;
|
|
reconnectingNotified = false;
|
|
options.onConnectionStateChange?.("connected");
|
|
handlers.onOpen?.();
|
|
};
|
|
|
|
source.onmessage = (event) => {
|
|
const messageEvent = event as MessageEvent;
|
|
if (!shouldDispatch(messageEvent)) return;
|
|
handlers.onMessage?.(messageEvent);
|
|
};
|
|
|
|
for (const [eventName, handler] of Object.entries(handlers.events ?? {})) {
|
|
source.addEventListener(eventName, (event: Event) => {
|
|
const messageEvent = event as MessageEvent;
|
|
if (!shouldDispatch(messageEvent)) return;
|
|
handler(messageEvent);
|
|
});
|
|
}
|
|
|
|
source.onerror = () => {
|
|
if (closedByUser || eventSource !== source) return;
|
|
|
|
const readyState = source.readyState;
|
|
if (readyState === EventSource.CONNECTING) {
|
|
if (!reconnectingNotified) {
|
|
reconnectingNotified = true;
|
|
options.onConnectionStateChange?.("reconnecting");
|
|
}
|
|
return;
|
|
}
|
|
|
|
source.close();
|
|
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
options.onFatalError?.("Connection lost");
|
|
return;
|
|
}
|
|
|
|
reconnectingNotified = true;
|
|
options.onConnectionStateChange?.("reconnecting");
|
|
reconnectAttempts += 1;
|
|
|
|
const delayMs = Math.min(1000 * 2 ** (reconnectAttempts - 1), 30000);
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delayMs);
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
const handle = {
|
|
close: () => {
|
|
closedByUser = true;
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
eventSource?.close();
|
|
activeResilientEventSources.delete(handle);
|
|
},
|
|
isConnected: () => !closedByUser && eventSource?.readyState === EventSource.OPEN,
|
|
};
|
|
activeResilientEventSources.add(handle);
|
|
return handle;
|
|
}
|
|
|
|
export interface DevServerCandidate {
|
|
scriptName: string;
|
|
command: string;
|
|
packagePath: string;
|
|
confidence: number;
|
|
name: string;
|
|
cwd: string;
|
|
source: string;
|
|
workspaceName?: string;
|
|
label: string;
|
|
}
|
|
|
|
// Backward-compatible alias for backend naming in FN-2178 scope.
|
|
export type DetectedCandidate = DevServerCandidate;
|
|
|
|
export interface DevServerState {
|
|
id: string;
|
|
name: string;
|
|
status: "stopped" | "starting" | "running" | "failed";
|
|
command: string;
|
|
scriptName: string;
|
|
cwd: string;
|
|
pid?: number;
|
|
startedAt?: string;
|
|
previewUrl?: string;
|
|
detectedUrl?: string;
|
|
detectedPort?: number;
|
|
manualPreviewUrl?: string;
|
|
manualUrl?: string;
|
|
logs: string[];
|
|
exitCode?: number | null;
|
|
}
|
|
|
|
export type DevServerStatus = DevServerState;
|
|
|
|
export interface DevServerStartInput {
|
|
command: string;
|
|
scriptName?: string;
|
|
cwd?: string;
|
|
packagePath?: string;
|
|
}
|
|
|
|
export interface DevServerConfig {
|
|
selectedScript: string | null;
|
|
selectedSource: string | null;
|
|
selectedCommand: string | null;
|
|
previewUrlOverride: string | null;
|
|
detectedPreviewUrl: string | null;
|
|
selectedAt: string | null;
|
|
}
|
|
|
|
export interface DevServerLogHistoryEntry {
|
|
id: number;
|
|
text: string;
|
|
stream: "stdout" | "stderr";
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface DevServerLogHistoryResponse {
|
|
lines: DevServerLogHistoryEntry[];
|
|
totalLines: number;
|
|
}
|
|
|
|
export interface FetchDevServerLogHistoryOptions {
|
|
maxLines?: number;
|
|
offset?: number;
|
|
lastEventId?: number;
|
|
}
|
|
|
|
export interface DevServerConfig {
|
|
selectedScript: string | null;
|
|
selectedSource: string | null;
|
|
selectedCommand: string | null;
|
|
previewUrlOverride: string | null;
|
|
detectedPreviewUrl: string | null;
|
|
selectedAt: string | null;
|
|
}
|
|
|
|
interface BackendDevServerCandidate {
|
|
name: string;
|
|
command: string;
|
|
source?: string;
|
|
packageName?: string;
|
|
packagePath?: string;
|
|
confidence?: number;
|
|
}
|
|
|
|
interface BackendDevServerState {
|
|
id?: string;
|
|
name?: string;
|
|
status?: "stopped" | "starting" | "running" | "failed";
|
|
command?: string;
|
|
scriptId?: string;
|
|
cwd?: string;
|
|
pid?: number;
|
|
startedAt?: string;
|
|
previewUrl?: string;
|
|
detectedUrl?: string;
|
|
detectedPort?: number;
|
|
manualPreviewUrl?: string;
|
|
manualUrl?: string;
|
|
logHistory?: string[];
|
|
exitCode?: number | null;
|
|
}
|
|
|
|
interface BackendDevServerLogHistoryLine {
|
|
id?: number;
|
|
text?: string;
|
|
line?: string;
|
|
stream?: "stdout" | "stderr";
|
|
timestamp?: string;
|
|
}
|
|
|
|
interface BackendDevServerLogHistoryResponse {
|
|
lines?: BackendDevServerLogHistoryLine[];
|
|
totalLines?: number;
|
|
}
|
|
|
|
function mapBackendCandidateToFrontend(candidate: BackendDevServerCandidate): DevServerCandidate {
|
|
const source = typeof candidate.source === "string" && candidate.source.trim().length > 0
|
|
? candidate.source.trim()
|
|
: "root";
|
|
const cwd = source === "root" ? "." : source;
|
|
const scriptName = candidate.name;
|
|
const packagePath = typeof candidate.packagePath === "string" && candidate.packagePath.trim().length > 0
|
|
? candidate.packagePath.trim()
|
|
: cwd;
|
|
const confidence = typeof candidate.confidence === "number"
|
|
? candidate.confidence
|
|
: 1;
|
|
|
|
const locationLabel = source === "root" ? "root" : source;
|
|
const packageLabel = typeof candidate.packageName === "string" && candidate.packageName.trim().length > 0
|
|
? candidate.packageName.trim()
|
|
: "project";
|
|
|
|
return {
|
|
name: candidate.name,
|
|
command: candidate.command,
|
|
scriptName,
|
|
packagePath,
|
|
confidence,
|
|
cwd,
|
|
source,
|
|
workspaceName: typeof candidate.packageName === "string" ? candidate.packageName : undefined,
|
|
label: `${packageLabel} · ${scriptName} (${locationLabel})`,
|
|
};
|
|
}
|
|
|
|
function mapBackendStateToFrontend(state: BackendDevServerState): DevServerState {
|
|
const status = state.status;
|
|
const normalizedStatus = status === "starting" || status === "running" || status === "failed" || status === "stopped"
|
|
? status
|
|
: "stopped";
|
|
|
|
const previewUrl = typeof state.previewUrl === "string"
|
|
? state.previewUrl
|
|
: state.detectedUrl;
|
|
const manualPreviewUrl = typeof state.manualPreviewUrl === "string"
|
|
? state.manualPreviewUrl
|
|
: state.manualUrl;
|
|
|
|
return {
|
|
id: typeof state.id === "string" ? state.id : "",
|
|
name: typeof state.name === "string" && state.name.length > 0 ? state.name : "default",
|
|
status: normalizedStatus,
|
|
command: typeof state.command === "string" ? state.command : "",
|
|
scriptName: typeof state.scriptId === "string" ? state.scriptId : "",
|
|
cwd: typeof state.cwd === "string" ? state.cwd : "",
|
|
pid: state.pid,
|
|
startedAt: state.startedAt,
|
|
previewUrl,
|
|
detectedUrl: typeof state.detectedUrl === "string" ? state.detectedUrl : previewUrl,
|
|
detectedPort: state.detectedPort,
|
|
manualPreviewUrl,
|
|
manualUrl: typeof state.manualUrl === "string" ? state.manualUrl : manualPreviewUrl,
|
|
logs: Array.isArray(state.logHistory) ? state.logHistory : [],
|
|
exitCode: state.exitCode,
|
|
};
|
|
}
|
|
|
|
function normalizeDevServerLogLine(line: BackendDevServerLogHistoryLine, fallbackId: number): DevServerLogHistoryEntry {
|
|
return {
|
|
id: typeof line.id === "number" && Number.isFinite(line.id) ? line.id : fallbackId,
|
|
text: typeof line.text === "string" ? line.text : (typeof line.line === "string" ? line.line : ""),
|
|
stream: line.stream === "stderr" ? "stderr" : "stdout",
|
|
timestamp: typeof line.timestamp === "string" ? line.timestamp : "",
|
|
};
|
|
}
|
|
|
|
function normalizeDevServerLogHistoryResponse(response: BackendDevServerLogHistoryResponse): DevServerLogHistoryResponse {
|
|
const rawLines = Array.isArray(response.lines) ? response.lines : [];
|
|
const lines = rawLines.map((line, index) => normalizeDevServerLogLine(line, index + 1));
|
|
|
|
return {
|
|
lines,
|
|
totalLines: typeof response.totalLines === "number" && Number.isFinite(response.totalLines)
|
|
? response.totalLines
|
|
: lines.length,
|
|
};
|
|
}
|
|
|
|
function mapLegacyDevServerLogs(logs: string[], options: FetchDevServerLogHistoryOptions): DevServerLogHistoryResponse {
|
|
const maxLines = typeof options.maxLines === "number" && Number.isFinite(options.maxLines)
|
|
? Math.max(1, Math.floor(options.maxLines))
|
|
: 100;
|
|
const offset = typeof options.offset === "number" && Number.isFinite(options.offset)
|
|
? Math.max(0, Math.floor(options.offset))
|
|
: 0;
|
|
const lastEventId = typeof options.lastEventId === "number" && Number.isFinite(options.lastEventId)
|
|
? Math.max(0, Math.floor(options.lastEventId))
|
|
: null;
|
|
|
|
const totalLines = logs.length;
|
|
const fullLines = logs.map<DevServerLogHistoryEntry>((text, index) => ({
|
|
id: index + 1,
|
|
text,
|
|
stream: "stdout",
|
|
timestamp: "",
|
|
}));
|
|
|
|
if (lastEventId !== null) {
|
|
return {
|
|
lines: fullLines.filter((line) => line.id > lastEventId).slice(0, maxLines),
|
|
totalLines,
|
|
};
|
|
}
|
|
|
|
const endExclusive = Math.max(totalLines - offset, 0);
|
|
const start = Math.max(endExclusive - maxLines, 0);
|
|
|
|
return {
|
|
lines: fullLines.slice(start, endExclusive),
|
|
totalLines,
|
|
};
|
|
}
|
|
|
|
type DevServerCandidatesResponse =
|
|
| { candidates?: BackendDevServerCandidate[] }
|
|
| BackendDevServerCandidate[];
|
|
|
|
function mapCandidatesResponse(response: DevServerCandidatesResponse): DevServerCandidate[] {
|
|
if (Array.isArray(response)) {
|
|
return response.map(mapBackendCandidateToFrontend);
|
|
}
|
|
|
|
return (response.candidates ?? []).map(mapBackendCandidateToFrontend);
|
|
}
|
|
|
|
export async function fetchDevServerCandidates(projectId?: string): Promise<DevServerCandidate[]> {
|
|
try {
|
|
const response = await api<DevServerCandidatesResponse>(withProjectId("/dev-server/candidates", projectId));
|
|
return mapCandidatesResponse(response);
|
|
} catch (error) {
|
|
// Backward compatibility for workspaces that still expose /dev-server/detect.
|
|
if (error instanceof Error && /\/dev-server\/candidates/.test(error.message)) {
|
|
const fallback = await api<DevServerCandidatesResponse>(withProjectId("/dev-server/detect", projectId));
|
|
return mapCandidatesResponse(fallback);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function detectDevServer(projectId?: string): Promise<DevServerCandidate[]> {
|
|
return fetchDevServerCandidates(projectId);
|
|
}
|
|
|
|
export function fetchDevServerConfig(projectId?: string): Promise<DevServerConfig> {
|
|
return api<DevServerConfig>(withProjectId("/dev-server/config", projectId));
|
|
}
|
|
|
|
export function saveDevServerConfig(config: Partial<DevServerConfig>, projectId?: string): Promise<DevServerConfig> {
|
|
return api<DevServerConfig>(withProjectId("/dev-server/config", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify(config),
|
|
});
|
|
}
|
|
|
|
export function fetchDevServerStatus(projectId?: string): Promise<DevServerState> {
|
|
return api<BackendDevServerState>(withProjectId("/dev-server/status", projectId)).then(mapBackendStateToFrontend);
|
|
}
|
|
|
|
export async function fetchDevServerLogHistory(
|
|
options: FetchDevServerLogHistoryOptions = {},
|
|
projectId?: string,
|
|
): Promise<DevServerLogHistoryResponse> {
|
|
const query = new URLSearchParams();
|
|
if (typeof options.maxLines === "number" && Number.isFinite(options.maxLines)) {
|
|
query.set("maxLines", String(Math.max(1, Math.floor(options.maxLines))));
|
|
}
|
|
if (typeof options.offset === "number" && Number.isFinite(options.offset)) {
|
|
query.set("offset", String(Math.max(0, Math.floor(options.offset))));
|
|
}
|
|
if (typeof options.lastEventId === "number" && Number.isFinite(options.lastEventId)) {
|
|
query.set("lastEventId", String(Math.max(0, Math.floor(options.lastEventId))));
|
|
}
|
|
|
|
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
|
|
try {
|
|
const response = await api<BackendDevServerLogHistoryResponse>(
|
|
withProjectId(`/dev-server/logs/history${suffix}`, projectId),
|
|
);
|
|
return normalizeDevServerLogHistoryResponse(response);
|
|
} catch (error) {
|
|
// Backward compatibility for workspaces without /dev-server/logs/history.
|
|
if (error instanceof Error && /\/dev-server\/logs\/history/.test(error.message)) {
|
|
const status = await fetchDevServerStatus(projectId);
|
|
return mapLegacyDevServerLogs(status.logs, options);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function startDevServer(body: DevServerStartInput, projectId?: string): Promise<DevServerState> {
|
|
const cwd = body.cwd ?? body.packagePath ?? ".";
|
|
const scriptName = body.scriptName;
|
|
|
|
return api<BackendDevServerState>(withProjectId("/dev-server/start", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
command: body.command,
|
|
scriptName,
|
|
scriptId: scriptName,
|
|
cwd,
|
|
packagePath: body.packagePath,
|
|
}),
|
|
}).then(mapBackendStateToFrontend);
|
|
}
|
|
|
|
export function stopDevServer(projectId?: string): Promise<DevServerState> {
|
|
return api<BackendDevServerState>(withProjectId("/dev-server/stop", projectId), {
|
|
method: "POST",
|
|
}).then(mapBackendStateToFrontend);
|
|
}
|
|
|
|
export function restartDevServer(projectId?: string): Promise<DevServerState> {
|
|
return api<BackendDevServerState>(withProjectId("/dev-server/restart", projectId), {
|
|
method: "POST",
|
|
}).then(mapBackendStateToFrontend);
|
|
}
|
|
|
|
export async function setDevServerPreviewUrl(urlOrBody: string | { url: string | null }, projectId?: string): Promise<DevServerState> {
|
|
const body = typeof urlOrBody === "string"
|
|
? { url: urlOrBody }
|
|
: urlOrBody;
|
|
|
|
try {
|
|
const response = await api<BackendDevServerState>(withProjectId("/dev-server/preview-url", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
return mapBackendStateToFrontend(response);
|
|
} catch (error) {
|
|
// Backward compatibility for workspaces that still use PUT.
|
|
if (error instanceof Error && /\/dev-server\/preview-url/.test(error.message)) {
|
|
const fallback = await api<BackendDevServerState>(withProjectId("/dev-server/preview-url", projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
});
|
|
return mapBackendStateToFrontend(fallback);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function getDevServerLogsStreamUrl(projectId?: string): string {
|
|
return buildApiUrl(withProjectId("/dev-server/logs/stream", projectId));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Session-based DevServer API (FN-2184 / FN-2185)
|
|
// Target /api/devserver/* with fallback to /api/dev-server/* for migration safety
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Canonical session-based DevServer types.
|
|
* These align with the new session model introduced in FN-2184.
|
|
*/
|
|
|
|
// Detected dev server command (result of detectDevServerCommands)
|
|
export interface DetectedDevServerCommand {
|
|
name: string;
|
|
command: string;
|
|
cwd: string;
|
|
scriptName: string;
|
|
packagePath: string;
|
|
framework?: string;
|
|
}
|
|
|
|
// Dev server log entry format
|
|
export interface DevServerLogEntry {
|
|
timestamp: string;
|
|
stream: "stdout" | "stderr";
|
|
text: string;
|
|
}
|
|
|
|
// Preview URL response from backend
|
|
export interface DevServerPreviewResponse {
|
|
url: string | null;
|
|
source: "auto" | "manual" | null;
|
|
}
|
|
|
|
// Dev server runtime info (process details)
|
|
export interface DevServerRuntime {
|
|
pid: number;
|
|
startedAt: string;
|
|
exitCode?: number;
|
|
previewUrl?: string;
|
|
}
|
|
|
|
// Dev server configuration (saved settings)
|
|
export interface DevServerSessionConfig {
|
|
id: string;
|
|
name: string;
|
|
command: string;
|
|
cwd: string;
|
|
env?: Record<string, string>;
|
|
autoStart?: boolean;
|
|
}
|
|
|
|
// Full DevServer session combining config, status, runtime, and logs
|
|
export interface DevServerSession {
|
|
config: DevServerSessionConfig;
|
|
status: "stopped" | "starting" | "running" | "failed" | "stopping";
|
|
runtime?: DevServerRuntime;
|
|
previewUrl?: string;
|
|
logHistory: DevServerLogEntry[];
|
|
}
|
|
|
|
// Options for fetching log history
|
|
export interface FetchDevServerLogsOptions {
|
|
maxLines?: number;
|
|
offset?: number;
|
|
lastEventId?: number;
|
|
}
|
|
|
|
// Backend response shape for log history
|
|
interface BackendSessionLogResponse {
|
|
lines?: DevServerLogEntry[];
|
|
totalLines?: number;
|
|
}
|
|
|
|
// Backend response for preview endpoint
|
|
interface BackendPreviewResponse {
|
|
url?: string | null;
|
|
source?: string | null;
|
|
}
|
|
|
|
// Backend response for list sessions
|
|
interface BackendSessionsListResponse {
|
|
sessions?: DevServerSession[];
|
|
}
|
|
|
|
// Backend response for detect commands
|
|
interface BackendDetectCommandsResponse {
|
|
candidates?: DetectedDevServerCommand[];
|
|
}
|
|
|
|
/**
|
|
* Fetch all dev server sessions.
|
|
* Targets /api/devserver with fallback to /api/dev-server (legacy compatibility).
|
|
*/
|
|
export async function fetchDevServers(projectId?: string): Promise<DevServerSession[]> {
|
|
try {
|
|
const response = await api<BackendSessionsListResponse>(withProjectId("/devserver", projectId));
|
|
return response.sessions ?? [];
|
|
} catch {
|
|
// Fallback: try to get the legacy single-server state and wrap it in session format
|
|
try {
|
|
const legacy = await fetchDevServerStatus(projectId);
|
|
// Convert legacy state to session format
|
|
const session: DevServerSession = {
|
|
config: {
|
|
id: legacy.id ?? "default",
|
|
name: legacy.name ?? "Dev Server",
|
|
command: legacy.command ?? "",
|
|
cwd: legacy.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
return [session];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new dev server session.
|
|
* Targets /api/devserver with fallback to /api/dev-server/start (legacy compatibility).
|
|
*/
|
|
export async function createDevServer(
|
|
data: { command: string; cwd?: string; name?: string; env?: Record<string, string> },
|
|
projectId?: string,
|
|
): Promise<DevServerSession> {
|
|
const body = {
|
|
command: data.command,
|
|
cwd: data.cwd ?? ".",
|
|
name: data.name,
|
|
env: data.env,
|
|
};
|
|
|
|
try {
|
|
return await api<DevServerSession>(withProjectId("/devserver", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch {
|
|
// Fallback: use legacy start endpoint
|
|
const legacy = await startDevServer({ command: data.command, cwd: data.cwd }, projectId);
|
|
return {
|
|
config: {
|
|
id: legacy.id ?? "default",
|
|
name: legacy.name ?? data.name ?? "Dev Server",
|
|
command: legacy.command,
|
|
cwd: legacy.cwd ?? data.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a specific dev server session by ID.
|
|
* Targets /api/devserver/:id with fallback to /api/dev-server/status (legacy compatibility).
|
|
*/
|
|
export async function fetchDevServer(id: string, projectId?: string): Promise<DevServerSession | null> {
|
|
try {
|
|
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId));
|
|
} catch {
|
|
// Fallback: try legacy status endpoint (single-server model)
|
|
try {
|
|
const legacy = await fetchDevServerStatus(projectId);
|
|
// If no ID or ID matches default, return legacy state as session
|
|
if (!id || id === "default" || id === legacy.id) {
|
|
return {
|
|
config: {
|
|
id: legacy.id ?? "default",
|
|
name: legacy.name ?? "Dev Server",
|
|
command: legacy.command ?? "",
|
|
cwd: legacy.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a specific dev server by ID.
|
|
* Targets /api/devserver/:id/start with fallback to /api/dev-server/start (legacy compatibility).
|
|
*/
|
|
export async function startDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
|
try {
|
|
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/start`, projectId), {
|
|
method: "POST",
|
|
});
|
|
} catch {
|
|
// Fallback: use legacy start endpoint (single-server model)
|
|
const legacy = await startDevServer({ command: "" }, projectId);
|
|
return {
|
|
config: {
|
|
id: legacy.id ?? id,
|
|
name: legacy.name ?? "Dev Server",
|
|
command: legacy.command ?? "",
|
|
cwd: legacy.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop a specific dev server by ID.
|
|
* Targets /api/devserver/:id/stop with fallback to /api/dev-server/stop (legacy compatibility).
|
|
*/
|
|
export async function stopDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
|
try {
|
|
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/stop`, projectId), {
|
|
method: "POST",
|
|
});
|
|
} catch {
|
|
// Fallback: use legacy stop endpoint
|
|
const legacy = await stopDevServer(projectId);
|
|
return {
|
|
config: {
|
|
id: legacy.id ?? id,
|
|
name: legacy.name ?? "Dev Server",
|
|
command: legacy.command ?? "",
|
|
cwd: legacy.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restart a specific dev server by ID.
|
|
* Targets /api/devserver/:id/restart with fallback to /api/dev-server/restart (legacy compatibility).
|
|
*/
|
|
export async function restartDevServerById(id: string, projectId?: string): Promise<DevServerSession> {
|
|
try {
|
|
return await api<DevServerSession>(withProjectId(`/devserver/${encodeURIComponent(id)}/restart`, projectId), {
|
|
method: "POST",
|
|
});
|
|
} catch {
|
|
// Fallback: use legacy restart endpoint
|
|
const legacy = await restartDevServer(projectId);
|
|
return {
|
|
config: {
|
|
id: legacy.id ?? id,
|
|
name: legacy.name ?? "Dev Server",
|
|
command: legacy.command ?? "",
|
|
cwd: legacy.cwd ?? ".",
|
|
},
|
|
status: legacy.status,
|
|
runtime: legacy.pid
|
|
? {
|
|
pid: legacy.pid,
|
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
|
exitCode: legacy.exitCode ?? undefined,
|
|
previewUrl: legacy.previewUrl,
|
|
}
|
|
: undefined,
|
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? undefined,
|
|
logHistory: (legacy.logs ?? []).map<DevServerLogEntry>((text) => ({
|
|
timestamp: new Date().toISOString(),
|
|
stream: text.startsWith("[stderr]") ? "stderr" : "stdout",
|
|
text: text.replace(/^\[stderr\]\s*/, ""),
|
|
})),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a specific dev server by ID.
|
|
* Targets /api/devserver/:id with fallback (no legacy equivalent).
|
|
*/
|
|
export async function deleteDevServer(id: string, projectId?: string): Promise<void> {
|
|
try {
|
|
await api<void>(withProjectId(`/devserver/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
} catch {
|
|
// No fallback for delete in legacy API (single-server model)
|
|
// Silently ignore - deletion may not be supported in legacy mode
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch logs for a specific dev server by ID.
|
|
* Targets /api/devserver/:id/logs with fallback to /api/dev-server/logs/history (legacy compatibility).
|
|
*/
|
|
export async function fetchDevServerLogs(
|
|
id: string,
|
|
opts: FetchDevServerLogsOptions = {},
|
|
projectId?: string,
|
|
): Promise<{ lines: DevServerLogEntry[]; totalLines: number }> {
|
|
const query = new URLSearchParams();
|
|
if (typeof opts.maxLines === "number" && Number.isFinite(opts.maxLines)) {
|
|
query.set("maxLines", String(Math.max(1, Math.floor(opts.maxLines))));
|
|
}
|
|
if (typeof opts.offset === "number" && Number.isFinite(opts.offset)) {
|
|
query.set("offset", String(Math.max(0, Math.floor(opts.offset))));
|
|
}
|
|
if (typeof opts.lastEventId === "number" && Number.isFinite(opts.lastEventId)) {
|
|
query.set("lastEventId", String(Math.max(0, Math.floor(opts.lastEventId))));
|
|
}
|
|
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
|
|
try {
|
|
const response = await api<BackendSessionLogResponse>(
|
|
withProjectId(`/devserver/${encodeURIComponent(id)}/logs${suffix}`, projectId),
|
|
);
|
|
return {
|
|
lines: response.lines ?? [],
|
|
totalLines: response.totalLines ?? response.lines?.length ?? 0,
|
|
};
|
|
} catch {
|
|
// Fallback: use legacy log history endpoint
|
|
try {
|
|
const response = await fetchDevServerLogHistory(opts, projectId);
|
|
return {
|
|
lines: response.lines.map<DevServerLogEntry>((entry) => ({
|
|
timestamp: entry.timestamp,
|
|
stream: entry.stream,
|
|
text: entry.text,
|
|
})),
|
|
totalLines: response.totalLines,
|
|
};
|
|
} catch {
|
|
return { lines: [], totalLines: 0 };
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch preview URL for a specific dev server by ID.
|
|
* Targets /api/devserver/:id/preview with fallback to /api/dev-server/status (legacy compatibility).
|
|
*/
|
|
export async function fetchDevServerPreview(id: string, projectId?: string): Promise<DevServerPreviewResponse> {
|
|
try {
|
|
const response = await api<BackendPreviewResponse>(
|
|
withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId),
|
|
);
|
|
return {
|
|
url: response.url ?? null,
|
|
source: (response.source as DevServerPreviewResponse["source"]) ?? null,
|
|
};
|
|
} catch {
|
|
// Fallback: use legacy status endpoint
|
|
try {
|
|
const legacy = await fetchDevServerStatus(projectId);
|
|
return {
|
|
url: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
|
|
source: legacy.manualUrl ? "manual" : "auto",
|
|
};
|
|
} catch {
|
|
return { url: null, source: null };
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set preview URL for a specific dev server by ID.
|
|
* Targets /api/devserver/:id/preview with fallback to /api/dev-server/preview-url (legacy compatibility).
|
|
*/
|
|
export async function setDevServerPreviewUrlById(
|
|
id: string,
|
|
url: string | null,
|
|
projectId?: string,
|
|
): Promise<DevServerPreviewResponse> {
|
|
try {
|
|
const response = await api<BackendPreviewResponse>(
|
|
withProjectId(`/devserver/${encodeURIComponent(id)}/preview`, projectId),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ url }),
|
|
},
|
|
);
|
|
return {
|
|
url: response.url ?? null,
|
|
source: (response.source as DevServerPreviewResponse["source"]) ?? null,
|
|
};
|
|
} catch {
|
|
// Fallback: use legacy preview URL endpoint
|
|
const legacy = await setDevServerPreviewUrl({ url }, projectId);
|
|
return {
|
|
url: legacy.previewUrl ?? legacy.manualUrl ?? null,
|
|
source: "manual",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect available dev server commands.
|
|
* Targets /api/devserver/detect with fallback to /api/dev-server/detect (legacy compatibility).
|
|
*/
|
|
export async function detectDevServerCommands(projectId?: string): Promise<DetectedDevServerCommand[]> {
|
|
try {
|
|
const response = await api<BackendDetectCommandsResponse>(withProjectId("/devserver/detect", projectId));
|
|
return response.candidates ?? [];
|
|
} catch {
|
|
// Fallback: use legacy detect endpoint
|
|
try {
|
|
const legacy = await fetchDevServerCandidates(projectId);
|
|
return legacy.map<DetectedDevServerCommand>((candidate) => ({
|
|
name: candidate.name,
|
|
command: candidate.command,
|
|
cwd: candidate.cwd,
|
|
scriptName: candidate.scriptName,
|
|
packagePath: candidate.packagePath,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the SSE stream URL for a specific dev server session's logs.
|
|
* Targets /api/devserver/:id/logs/stream with fallback to /api/dev-server/logs/stream (legacy compatibility).
|
|
*/
|
|
export function getDevServerSessionLogsStreamUrl(id: string, projectId?: string): string {
|
|
// Try new session-scoped endpoint first
|
|
return buildApiUrl(withProjectId(`/devserver/${encodeURIComponent(id)}/logs/stream`, projectId));
|
|
}
|
|
|
|
function startKeepAlive(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
intervalMs = 25_000,
|
|
): { stop: () => void } {
|
|
const timer = setInterval(() => {
|
|
void pingSession(sessionId, projectId).catch(() => {
|
|
// Best-effort keepalive: ignore failures so streams remain active.
|
|
});
|
|
}, intervalMs);
|
|
|
|
return {
|
|
stop: () => {
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 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));
|
|
}
|
|
|
|
export function getAgentOnboardingStreamUrl(sessionId: string, projectId?: string): string {
|
|
return buildApiUrl(withProjectId(`/agents/onboarding/${encodeURIComponent(sessionId)}/stream`, projectId));
|
|
}
|
|
|
|
export function connectAgentOnboardingStream(
|
|
sessionId: string,
|
|
projectId: string | undefined,
|
|
handlers: {
|
|
onThinking?: (data: string) => void;
|
|
onQuestion?: (data: PlanningQuestion) => void;
|
|
onSummary?: (data: AgentOnboardingSummary) => void;
|
|
onError?: (data: string) => void;
|
|
onComplete?: () => void;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = getAgentOnboardingStreamUrl(sessionId, projectId);
|
|
const resilient = createResilientEventSource(
|
|
url,
|
|
{
|
|
events: {
|
|
thinking: (event) => {
|
|
try { handlers.onThinking?.(JSON.parse(event.data)); } catch { handlers.onThinking?.(event.data); }
|
|
},
|
|
question: (event) => {
|
|
try { handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion); } catch { /* ignore parse error */ }
|
|
},
|
|
summary: (event) => {
|
|
try { handlers.onSummary?.(JSON.parse(event.data) as AgentOnboardingSummary); } catch { /* ignore parse error */ }
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(event.data || "Stream error");
|
|
}
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => handlers.onError?.(message),
|
|
},
|
|
);
|
|
|
|
return {
|
|
close: resilient.close,
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
}
|
|
|
|
/** Connect to planning session SSE stream and handle events
|
|
*
|
|
* Returns an object with:
|
|
* - close: function to close the connection
|
|
*/
|
|
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;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = getPlanningStreamUrl(sessionId, projectId);
|
|
let keepAlive: { stop: () => void } | null = null;
|
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
|
|
|
const stopKeepAlive = () => {
|
|
keepAlive?.stop();
|
|
keepAlive = null;
|
|
};
|
|
|
|
const resilient = createResilientEventSource(
|
|
url,
|
|
{
|
|
onOpen: () => {
|
|
stopKeepAlive();
|
|
keepAlive = startKeepAlive(sessionId, projectId);
|
|
},
|
|
onMessage: (event) => {
|
|
if (event.data.startsWith(":")) return;
|
|
},
|
|
events: {
|
|
thinking: (event) => {
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(event.data));
|
|
} catch {
|
|
handlers.onThinking?.(event.data);
|
|
}
|
|
},
|
|
question: (event) => {
|
|
try {
|
|
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
|
|
} catch (err) {
|
|
console.error("[planning] Failed to parse question event:", err);
|
|
}
|
|
},
|
|
summary: (event) => {
|
|
try {
|
|
handlers.onSummary?.(JSON.parse(event.data) as PlanningSummary);
|
|
} catch (err) {
|
|
console.error("[planning] Failed to parse summary event:", err);
|
|
}
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(event.data || "Stream error");
|
|
}
|
|
connection?.close();
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
connection?.close();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => {
|
|
stopKeepAlive();
|
|
handlers.onError?.(message);
|
|
},
|
|
},
|
|
);
|
|
|
|
connection = {
|
|
close: () => {
|
|
stopKeepAlive();
|
|
resilient.close();
|
|
},
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
|
|
return connection;
|
|
}
|
|
|
|
// ── Automation / Scheduled Tasks ──────────────────────────────────
|
|
|
|
/**
|
|
* Options for scheduling scope (global vs project-scoped automations/routines).
|
|
* When scope is "project", projectId must be provided.
|
|
*/
|
|
export type SchedulingScopeOptions = {
|
|
/** Scope for scheduling operations: "global" or "project". Defaults to "project" on the server. */
|
|
scope?: "global" | "project";
|
|
/** Project ID required when scope is "project". */
|
|
projectId?: string;
|
|
};
|
|
|
|
/**
|
|
* Build URL suffix with scope and projectId query params.
|
|
* Mirrors the backend's parseScopeParam logic: scope goes in query param.
|
|
*/
|
|
function withSchedulingScope(path: string, options?: SchedulingScopeOptions): string {
|
|
const params = new URLSearchParams();
|
|
if (options?.scope) {
|
|
params.set("scope", options.scope);
|
|
}
|
|
if (options?.projectId) {
|
|
params.set("projectId", options.projectId);
|
|
}
|
|
const suffix = params.toString();
|
|
if (!suffix) return path;
|
|
return `${path}?${suffix}`;
|
|
}
|
|
|
|
/** Response from the manual run trigger endpoint. */
|
|
export interface AutomationRunResponse {
|
|
schedule: ScheduledTask;
|
|
result: AutomationRunResult;
|
|
}
|
|
|
|
export function fetchAutomations(options?: SchedulingScopeOptions): Promise<ScheduledTask[]> {
|
|
return api<ScheduledTask[]>(withSchedulingScope("/automations", options));
|
|
}
|
|
|
|
export function fetchAutomation(id: string, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
|
|
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}`, options));
|
|
}
|
|
|
|
export function createAutomation(input: ScheduledTaskCreateInput, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
|
|
// Forward all input fields including scope metadata (scope may be set on input or in options)
|
|
return api<ScheduledTask>(withSchedulingScope("/automations", options), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function updateAutomation(id: string, updates: ScheduledTaskUpdateInput, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
|
|
// Forward all update fields including scope metadata
|
|
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}`, options), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
export async function deleteAutomation(id: string, options?: SchedulingScopeOptions): Promise<void> {
|
|
await api(withSchedulingScope(`/automations/${id}`, options), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function runAutomation(id: string, options?: SchedulingScopeOptions): Promise<AutomationRunResponse> {
|
|
return api<AutomationRunResponse>(withSchedulingScope(`/automations/${id}/run`, options), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function toggleAutomation(id: string, options?: SchedulingScopeOptions): Promise<ScheduledTask> {
|
|
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}/toggle`, options), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function reorderAutomationSteps(id: string, stepIds: string[], options?: SchedulingScopeOptions): Promise<ScheduledTask> {
|
|
return api<ScheduledTask>(withSchedulingScope(`/automations/${id}/steps/reorder`, options), {
|
|
method: "POST",
|
|
body: JSON.stringify({ stepIds }),
|
|
});
|
|
}
|
|
|
|
// ── Routines API ────────────────────────────────────────────────
|
|
|
|
export interface RoutineRunResponse {
|
|
routine: Routine;
|
|
result: RoutineExecutionResult;
|
|
}
|
|
|
|
export function fetchRoutines(options?: SchedulingScopeOptions): Promise<Routine[]> {
|
|
return api<Routine[]>(withSchedulingScope("/routines", options));
|
|
}
|
|
|
|
export function fetchRoutine(id: string, options?: SchedulingScopeOptions): Promise<Routine> {
|
|
return api<Routine>(withSchedulingScope(`/routines/${id}`, options));
|
|
}
|
|
|
|
export function createRoutine(input: RoutineCreateInput, options?: SchedulingScopeOptions): Promise<Routine> {
|
|
// Forward all input fields including scope metadata
|
|
return api<Routine>(withSchedulingScope("/routines", options), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function updateRoutine(id: string, updates: RoutineUpdateInput, options?: SchedulingScopeOptions): Promise<Routine> {
|
|
// Forward all update fields including scope metadata
|
|
return api<Routine>(withSchedulingScope(`/routines/${id}`, options), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
export async function deleteRoutine(id: string, options?: SchedulingScopeOptions): Promise<void> {
|
|
await api(withSchedulingScope(`/routines/${id}`, options), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function runRoutine(id: string, options?: SchedulingScopeOptions): Promise<RoutineRunResponse> {
|
|
return api<RoutineRunResponse>(withSchedulingScope(`/routines/${id}/trigger`, options), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function fetchRoutineRuns(id: string, options?: SchedulingScopeOptions): Promise<RoutineExecutionResult[]> {
|
|
return api<RoutineExecutionResult[]>(withSchedulingScope(`/routines/${id}/runs`, options));
|
|
}
|
|
|
|
export function triggerRoutineWebhook(id: string, payload?: Record<string, unknown>, options?: SchedulingScopeOptions): Promise<RoutineRunResponse> {
|
|
return api<RoutineRunResponse>(withSchedulingScope(`/routines/${id}/webhook`, options), {
|
|
method: "POST",
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
});
|
|
}
|
|
|
|
// ── 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; projectId?: string }): 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);
|
|
if (options?.projectId) search.set("projectId", options.projectId);
|
|
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
|
return api<ActivityLogEntry[]>(`/activity${suffix}`);
|
|
}
|
|
|
|
/** Clear all activity log entries */
|
|
export function clearActivityLog(projectId?: string): Promise<{ success: boolean }> {
|
|
const path = withProjectId("/activity", projectId);
|
|
return api<{ success: boolean }>(path, { 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");
|
|
}
|
|
|
|
/** Fetch plugin-contributed workflow step templates */
|
|
export function fetchPluginWorkflowStepTemplates(): Promise<{
|
|
templates: Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
|
}> {
|
|
return api<{
|
|
templates: Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
|
}>("/plugin-workflow-step-templates");
|
|
}
|
|
|
|
/** Create a workflow step from a built-in or plugin 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
|
|
* @param projectId - Optional project ID for scoped settings resolution
|
|
* @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, projectId?: string): Promise<string> {
|
|
const response = await api<RefineTextResponse>(withProjectId("/ai/refine-text", projectId), {
|
|
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 retrySubtaskSession(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ success: boolean; sessionId: string }> {
|
|
return api<{ success: boolean; sessionId: string }>(
|
|
withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId),
|
|
{
|
|
method: "POST",
|
|
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
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;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
let keepAlive: { stop: () => void } | null = null;
|
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
|
|
|
const stopKeepAlive = () => {
|
|
keepAlive?.stop();
|
|
keepAlive = null;
|
|
};
|
|
|
|
const resilient = createResilientEventSource(
|
|
getSubtaskStreamUrl(sessionId, projectId),
|
|
{
|
|
onOpen: () => {
|
|
stopKeepAlive();
|
|
keepAlive = startKeepAlive(sessionId, projectId);
|
|
},
|
|
events: {
|
|
thinking: (event) => {
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(event.data));
|
|
} catch {
|
|
handlers.onThinking?.(event.data);
|
|
}
|
|
},
|
|
subtasks: (event) => {
|
|
try {
|
|
handlers.onSubtasks?.(JSON.parse(event.data) as SubtaskItem[]);
|
|
} catch (err) {
|
|
console.error("[subtasks] Failed to parse subtasks event:", err);
|
|
}
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsedData = JSON.parse(event.data);
|
|
const errorMessage = typeof parsedData === "string" && parsedData.length > 0 ? parsedData : null;
|
|
handlers.onError?.(errorMessage || "Stream error");
|
|
} catch {
|
|
handlers.onError?.("Stream error");
|
|
}
|
|
connection?.close();
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
connection?.close();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => {
|
|
stopKeepAlive();
|
|
handlers.onError?.(message);
|
|
},
|
|
},
|
|
);
|
|
|
|
connection = {
|
|
close: () => {
|
|
stopKeepAlive();
|
|
resilient.close();
|
|
},
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
|
|
return connection;
|
|
}
|
|
|
|
export function createTasksFromBreakdown(
|
|
sessionId: string,
|
|
subtasks: SubtaskItem[],
|
|
parentTaskId?: string,
|
|
projectId?: string,
|
|
options?: {
|
|
branch?: string;
|
|
baseBranch?: string;
|
|
branchSelection?: {
|
|
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
};
|
|
branchAssignment?: { mode: "shared" | "per-task-derived" };
|
|
},
|
|
): Promise<{ tasks: Task[]; parentTaskClosed?: boolean }> {
|
|
return api<{ tasks: Task[]; parentTaskClosed?: boolean }>(withProjectId("/subtasks/create-tasks", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
parentTaskId,
|
|
...(options?.branch !== undefined ? { branch: options.branch } : {}),
|
|
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
|
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
|
...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}),
|
|
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, tabId?: string): Promise<void> {
|
|
return api<void>(withProjectId("/subtasks/cancel", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, tabId }),
|
|
});
|
|
}
|
|
|
|
// ── Agent API ────────────────────────────────────────────────────────────
|
|
|
|
import type {
|
|
Agent,
|
|
AgentDetail,
|
|
AgentCapability,
|
|
AgentState,
|
|
AgentHeartbeatEvent,
|
|
AgentHeartbeatRun,
|
|
AgentCreateInput,
|
|
AgentUpdateInput,
|
|
AgentTaskSession,
|
|
AgentStats,
|
|
HeartbeatInvocationSource,
|
|
OrgTreeNode,
|
|
AgentReflection,
|
|
AgentPerformanceSummary,
|
|
ReflectionTrigger,
|
|
AgentBudgetStatus,
|
|
} from "@fusion/core";
|
|
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput, AgentTaskSession, AgentStats, HeartbeatInvocationSource, OrgTreeNode, AgentReflection, AgentPerformanceSummary, ReflectionTrigger, AgentBudgetStatus };
|
|
|
|
function withProjectId(path: string, projectId?: string): string {
|
|
if (!projectId) return path;
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
|
|
}
|
|
|
|
/**
|
|
* Rewrite a path to route through the node proxy when viewing a remote node.
|
|
* When nodeId is provided and differs from localNodeId (i.e., it's a remote node),
|
|
* rewrites the path from `/tasks` to `/proxy/${encodeURIComponent(nodeId)}/tasks`.
|
|
* When nodeId is undefined or matches localNodeId, returns the path unchanged.
|
|
*/
|
|
export function withNodeId(path: string, nodeId?: string, localNodeId?: string): string {
|
|
if (!nodeId || nodeId === localNodeId) return path;
|
|
// Rewrite path to proxy endpoint: /tasks -> /proxy/:nodeId/tasks
|
|
// Strip leading /api prefix if present since proxyApi adds it
|
|
const apiPrefix = "/api";
|
|
const pathWithoutPrefix = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) : path;
|
|
return `/proxy/${encodeURIComponent(nodeId)}${pathWithoutPrefix}`;
|
|
}
|
|
|
|
/**
|
|
* Make an API request, optionally routing through the node proxy for remote nodes.
|
|
* When nodeId is provided and differs from localNodeId, the request is routed
|
|
* through /api/proxy/:nodeId/... instead of directly.
|
|
*/
|
|
export function proxyApi<T>(path: string, opts?: RequestInit & { nodeId?: string; localNodeId?: string }): Promise<T> {
|
|
// Extract nodeId/localNodeId from opts before passing to api()
|
|
const { nodeId, localNodeId, ...fetchOpts } = opts ?? {};
|
|
const resolvedPath = withNodeId(path, nodeId, localNodeId);
|
|
return api<T>(resolvedPath, fetchOpts);
|
|
}
|
|
|
|
/** Fetch all agents, optionally filtered by state or role */
|
|
export function fetchAgents(
|
|
filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean },
|
|
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 (filter?.includeEphemeral === true) params.set("includeEphemeral", "true");
|
|
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),
|
|
});
|
|
}
|
|
|
|
/** Upload an agent avatar image. */
|
|
export async function uploadAgentAvatar(agentId: string, file: File, projectId?: string): Promise<Agent> {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await fetch(buildApiUrl(withProjectId(`/agents/${encodeURIComponent(agentId)}/avatar`, projectId)), {
|
|
method: "POST",
|
|
headers: withTokenHeader(),
|
|
body: formData,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error((data as { error?: string }).error || "Avatar upload failed");
|
|
}
|
|
return data as Agent;
|
|
}
|
|
|
|
/** Delete an agent avatar image. */
|
|
export function deleteAgentAvatar(agentId: string, projectId?: string): Promise<Agent> {
|
|
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/avatar`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Backfill an existing agent onto the default heartbeat procedure file. */
|
|
export function upgradeAgentHeartbeatProcedure(
|
|
agentId: string,
|
|
projectId?: string,
|
|
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
|
|
return api(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
|
|
{ method: "POST" },
|
|
);
|
|
}
|
|
|
|
/** Update agent custom instructions */
|
|
export function updateAgentInstructions(
|
|
agentId: string,
|
|
instructions: { instructionsPath?: string; instructionsText?: string },
|
|
projectId?: string,
|
|
): Promise<Agent> {
|
|
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/instructions`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(instructions),
|
|
});
|
|
}
|
|
|
|
/** Fetch agent soul/personality text */
|
|
export function fetchAgentSoul(agentId: string, projectId?: string): Promise<{ soul: string | null }> {
|
|
return api<{ soul: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId));
|
|
}
|
|
|
|
/** Update agent soul/personality text */
|
|
export function updateAgentSoul(agentId: string, soul: string, projectId?: string): Promise<Agent> {
|
|
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/soul`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ soul }),
|
|
});
|
|
}
|
|
|
|
/** Fetch per-agent memory text */
|
|
export function fetchAgentMemory(agentId: string, projectId?: string): Promise<{ memory: string | null }> {
|
|
return api<{ memory: string | null }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId));
|
|
}
|
|
|
|
/** Update per-agent memory text */
|
|
export function updateAgentMemory(agentId: string, memory: string, projectId?: string): Promise<Agent> {
|
|
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ memory }),
|
|
});
|
|
}
|
|
|
|
/** List file-based memory entries for a specific agent */
|
|
export function fetchAgentMemoryFiles(agentId: string, projectId?: string): Promise<{ files: MemoryFileInfo[] }> {
|
|
return api<{ files: MemoryFileInfo[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/files`, projectId));
|
|
}
|
|
|
|
/** Read one file-based memory entry for a specific agent */
|
|
export function fetchAgentMemoryFile(agentId: string, path: string, projectId?: string): Promise<{ path: string; content: string }> {
|
|
const query = `path=${encodeURIComponent(path)}`;
|
|
return api<{ path: string; content: string }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file?${query}`, projectId));
|
|
}
|
|
|
|
/** Save one file-based memory entry for a specific agent */
|
|
export function saveAgentMemoryFile(agentId: string, path: string, content: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory/file`, projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ path, content }),
|
|
});
|
|
}
|
|
|
|
/** 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}`);
|
|
}
|
|
|
|
/** Fetch heartbeat runs for an agent */
|
|
export function fetchAgentRuns(agentId: string, limit?: number, projectId?: string): Promise<AgentHeartbeatRun[]> {
|
|
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<AgentHeartbeatRun[]>(`/agents/${encodeURIComponent(agentId)}/runs${query}`);
|
|
}
|
|
|
|
/** Fetch a single heartbeat run detail */
|
|
export function fetchAgentRunDetail(agentId: string, runId: string, projectId?: string): Promise<AgentHeartbeatRun> {
|
|
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, projectId));
|
|
}
|
|
|
|
/** Fetch agent logs for a specific run's time window */
|
|
export function fetchAgentRunLogs(agentId: string, runId: string, projectId?: string): Promise<AgentLogEntry[]> {
|
|
return api<AgentLogEntry[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/logs`, projectId));
|
|
}
|
|
|
|
/** Manually start a heartbeat run for an agent */
|
|
export function startAgentRun(
|
|
agentId: string,
|
|
projectId?: string,
|
|
options?: { source?: HeartbeatInvocationSource; triggerDetail?: string },
|
|
): Promise<AgentHeartbeatRun> {
|
|
const source = options?.source ?? "manual";
|
|
const triggerDetail = options?.triggerDetail ?? "Agent activated via dashboard";
|
|
return api<AgentHeartbeatRun>(withProjectId(`/agents/${encodeURIComponent(agentId)}/runs`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ source, triggerDetail }),
|
|
});
|
|
}
|
|
|
|
/** Stop an active heartbeat run for an agent */
|
|
export function stopAgentRun(
|
|
agentId: string,
|
|
projectId?: string,
|
|
): Promise<{ ok: boolean; runId?: string; message?: string }> {
|
|
return api<{ ok: boolean; runId?: string; message?: string }>(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/stop`, projectId),
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Run-Audit & Timeline API ────────────────────────────────────────────────
|
|
|
|
/** Valid domain filters for run-audit queries. */
|
|
export type RunAuditDomainFilter = "database" | "git" | "filesystem";
|
|
|
|
/** Filter options for run-audit queries. */
|
|
export interface RunAuditFilters {
|
|
/** Filter by task ID */
|
|
taskId?: string;
|
|
/** Filter by domain category */
|
|
domain?: RunAuditDomainFilter;
|
|
/** Start of time range (inclusive, ISO-8601) */
|
|
startTime?: string;
|
|
/** End of time range (inclusive, ISO-8601) */
|
|
endTime?: string;
|
|
/** Maximum number of events to return */
|
|
limit?: number;
|
|
}
|
|
|
|
/** Normalized run-audit event for UI consumption. */
|
|
export interface NormalizedRunAuditEvent {
|
|
id: string;
|
|
timestamp: string;
|
|
taskId?: string;
|
|
domain: "database" | "git" | "filesystem";
|
|
mutationType: string;
|
|
target: string;
|
|
summary: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Response shape for run-audit endpoint. */
|
|
export interface RunAuditResponse {
|
|
runId: string;
|
|
events: NormalizedRunAuditEvent[];
|
|
filters: {
|
|
taskId?: string;
|
|
domain?: RunAuditDomainFilter;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
};
|
|
totalCount: number;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
/** Unified timeline entry that can represent either an audit event or an agent log entry. */
|
|
export interface TimelineEntry {
|
|
timestamp: string;
|
|
type: "audit" | "log";
|
|
sortKey: string;
|
|
audit?: NormalizedRunAuditEvent;
|
|
log?: AgentLogEntry;
|
|
}
|
|
|
|
/** Response shape for run-timeline endpoint. */
|
|
export interface RunTimelineResponse {
|
|
run: {
|
|
id: string;
|
|
agentId: string;
|
|
startedAt: string;
|
|
endedAt?: string;
|
|
status: string;
|
|
taskId?: string;
|
|
};
|
|
auditByDomain: {
|
|
database: NormalizedRunAuditEvent[];
|
|
git: NormalizedRunAuditEvent[];
|
|
filesystem: NormalizedRunAuditEvent[];
|
|
};
|
|
counts: {
|
|
auditEvents: number;
|
|
logEntries: number;
|
|
};
|
|
timeline: TimelineEntry[];
|
|
}
|
|
|
|
/**
|
|
* Fetch normalized run-audit events for a specific agent run.
|
|
*
|
|
* @param agentId - The agent ID
|
|
* @param runId - The run ID
|
|
* @param filters - Optional filter parameters
|
|
* @param projectId - Optional project ID for multi-project workspaces
|
|
* @returns Promise resolving to RunAuditResponse with normalized events
|
|
* @throws Error if runId is blank or whitespace-only
|
|
*/
|
|
export function fetchAgentRunAudit(
|
|
agentId: string,
|
|
runId: string,
|
|
filters?: RunAuditFilters,
|
|
projectId?: string,
|
|
): Promise<RunAuditResponse> {
|
|
// Validate runId before making API call
|
|
if (!runId || runId.trim().length === 0) {
|
|
throw new Error("runId is required");
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (filters?.taskId) params.set("taskId", filters.taskId);
|
|
if (filters?.domain) params.set("domain", filters.domain);
|
|
if (filters?.startTime) params.set("startTime", filters.startTime);
|
|
if (filters?.endTime) params.set("endTime", filters.endTime);
|
|
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<RunAuditResponse>(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/audit${query}`, projectId),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch a correlated timeline combining run-audit events and agent logs for a specific run.
|
|
*
|
|
* @param agentId - The agent ID
|
|
* @param runId - The run ID
|
|
* @param options - Optional parameters
|
|
* @param options.taskId - Override task ID for audit filtering (defaults to run's contextSnapshot.taskId)
|
|
* @param options.domain - Filter audit events by domain
|
|
* @param options.startTime - Start of time range (ISO-8601)
|
|
* @param options.endTime - End of time range (ISO-8601)
|
|
* @param options.includeLogs - Whether to include agent logs (default true)
|
|
* @param options.limit - Maximum audit events to return
|
|
* @param projectId - Optional project ID for multi-project workspaces
|
|
* @returns Promise resolving to RunTimelineResponse with merged timeline
|
|
* @throws Error if runId is blank or whitespace-only
|
|
*/
|
|
export function fetchAgentRunTimeline(
|
|
agentId: string,
|
|
runId: string,
|
|
options?: {
|
|
taskId?: string;
|
|
domain?: RunAuditDomainFilter;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
includeLogs?: boolean;
|
|
limit?: number;
|
|
},
|
|
projectId?: string,
|
|
): Promise<RunTimelineResponse> {
|
|
// Validate runId before making API call
|
|
if (!runId || runId.trim().length === 0) {
|
|
throw new Error("runId is required");
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (options?.taskId) params.set("taskId", options.taskId);
|
|
if (options?.domain) params.set("domain", options.domain);
|
|
if (options?.startTime) params.set("startTime", options.startTime);
|
|
if (options?.endTime) params.set("endTime", options.endTime);
|
|
if (options?.includeLogs !== undefined) params.set("includeLogs", String(options.includeLogs));
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<RunTimelineResponse>(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/timeline${query}`, projectId),
|
|
);
|
|
}
|
|
|
|
/** Fetch aggregate agent stats */
|
|
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
|
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
|
}
|
|
|
|
/** Fetch the chain of command for an agent (self → manager → grand-manager → ...) */
|
|
export function fetchChainOfCommand(agentId: string, projectId?: string): Promise<Agent[]> {
|
|
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/chain-of-command`, projectId));
|
|
}
|
|
|
|
/** Fetch the full org tree as nested nodes */
|
|
export function fetchOrgTree(projectId?: string, options?: { includeEphemeral?: boolean }): Promise<OrgTreeNode[]> {
|
|
const params = new URLSearchParams();
|
|
if (projectId) params.set("projectId", projectId);
|
|
if (options?.includeEphemeral) params.set("includeEphemeral", "true");
|
|
const query = params.toString();
|
|
return api<OrgTreeNode[]>(`/agents/org-tree${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
/** Resolve an agent by shortname or ID */
|
|
export function resolveAgent(shortname: string, projectId?: string): Promise<{ agent: Agent }> {
|
|
return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId));
|
|
}
|
|
|
|
/** Fetch employees (agents that report to a given parent agent) */
|
|
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
|
|
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
|
|
// Return empty array for 404 (agent may have been deleted)
|
|
if (err.message.includes("not found")) return [];
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
/** Alias for fetchAgentChildren with employee-focused naming */
|
|
export const fetchAgentEmployees = fetchAgentChildren;
|
|
|
|
/** Assign or unassign a task to an explicit agent */
|
|
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ agentId }),
|
|
});
|
|
}
|
|
|
|
/** Assign or unassign a task to a user (for review handoff) */
|
|
export function assignTaskToUser(taskId: string, userId: string | null, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign-user`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ userId }),
|
|
});
|
|
}
|
|
|
|
/** Accept review - clear assignee and awaiting-user-review status, keep in in-review */
|
|
export function acceptTaskReview(taskId: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/accept-review`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
|
|
return {
|
|
reviewState: {
|
|
source: data.mode,
|
|
summary: data.summary ?? undefined,
|
|
items: data.items.map((item) => ({
|
|
id: item.itemId,
|
|
body: item.body,
|
|
author: { login: item.author },
|
|
createdAt: item.createdAt ?? new Date(0).toISOString(),
|
|
updatedAt: item.updatedAt ?? undefined,
|
|
path: item.filePath,
|
|
threadId: item.threadId,
|
|
htmlUrl: item.url,
|
|
state: item.reviewState ?? undefined,
|
|
isResolved: item.isResolved,
|
|
})),
|
|
addressing: [],
|
|
lastRefreshedAt: data.fetchedAt ?? undefined,
|
|
refreshStatus: "ready",
|
|
refreshSource: "initial-load",
|
|
},
|
|
automationStatus: null,
|
|
};
|
|
}
|
|
|
|
/** Fetch normalized task review data (PR mode or direct mode) */
|
|
export async function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
|
return mapTaskReviewDataToLegacy(data);
|
|
}
|
|
|
|
/** Fetch canonical review payload for future review-tab rendering. */
|
|
export function fetchTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
|
}
|
|
|
|
/** Refresh normalized task review data (PR mode or direct mode) */
|
|
export async function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
|
method: "POST",
|
|
});
|
|
return mapTaskReviewDataToLegacy(data);
|
|
}
|
|
|
|
/** Refresh canonical review payload for future review-tab rendering. */
|
|
export function refreshTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Request an in-place revision pass for selected review items */
|
|
export function reviseTaskReviewItems(taskId: string, selectedItems: SelectedReviewItem[], projectId?: string): Promise<ReviseTaskReviewResponse> {
|
|
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/address`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ selectedItems, tab: "review" }),
|
|
});
|
|
}
|
|
|
|
/** Return task to agent - clear assignee and status, move to todo */
|
|
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Fetch tasks explicitly assigned to an agent */
|
|
export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Task[]> {
|
|
return api<Task[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/tasks`, projectId));
|
|
}
|
|
|
|
// ── Agent Import API ────────────────────────────────────────────────────────
|
|
|
|
/** Company entry from companies.sh catalog */
|
|
export interface CompanyEntry {
|
|
slug: string;
|
|
name: string;
|
|
tagline?: string;
|
|
repo?: string;
|
|
website?: string;
|
|
installs?: number;
|
|
}
|
|
|
|
/** Response from companies.sh catalog API */
|
|
export interface CompaniesCatalogResponse {
|
|
companies: CompanyEntry[];
|
|
error?: string;
|
|
}
|
|
|
|
/** Result of importing agents from an Agent Companies source */
|
|
export interface AgentImportResult {
|
|
companyName?: string;
|
|
companySlug?: string;
|
|
agents?: Array<{ name: string; role: string; title?: string; skills?: string[] }>;
|
|
/** In dry-run mode: agent name strings. In live mode: agent objects with id and name. */
|
|
created: string[] | Array<{ id: string; name: string }>;
|
|
skipped: string[];
|
|
errors: Array<{ name: string; error: string }>;
|
|
dryRun?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Fetch companies from companies.sh catalog.
|
|
* Returns both companies and optional error message for proper error surfacing.
|
|
*/
|
|
export function fetchCompanies(): Promise<CompaniesCatalogResponse> {
|
|
return api<CompaniesCatalogResponse>("/agents/companies");
|
|
}
|
|
|
|
/**
|
|
* Import agents from an Agent Companies source via the API.
|
|
* Uses dryRun for preview, then actual import.
|
|
*
|
|
* Supports four input modes:
|
|
* - { manifest: string } - raw AGENTS.md content
|
|
* - { source: string } - server directory path
|
|
* - { agents: unknown[] } - parsed agent manifests
|
|
* - { importSource: "companies.sh", companySlug: string } - companies.sh catalog entry
|
|
*/
|
|
export function importAgents(
|
|
input:
|
|
| { manifest: string }
|
|
| { source: string }
|
|
| { agents: unknown[] }
|
|
| { importSource: "companies.sh"; companySlug: string },
|
|
options?: { dryRun?: boolean; skipExisting?: boolean },
|
|
projectId?: string,
|
|
): Promise<AgentImportResult> {
|
|
return api<AgentImportResult>(withProjectId("/agents/import", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
...input,
|
|
dryRun: options?.dryRun ?? false,
|
|
skipExisting: options?.skipExisting ?? true,
|
|
}),
|
|
});
|
|
}
|
|
|
|
// ── Agent Generation API ────────────────────────────────────────────────────
|
|
|
|
/** Generated agent specification returned by the AI */
|
|
export interface AgentGenerationSpec {
|
|
/** Display name for the agent */
|
|
title: string;
|
|
/** Single emoji icon */
|
|
icon: string;
|
|
/** Agent capability/role */
|
|
role: string;
|
|
/** Brief description of the agent's purpose */
|
|
description: string;
|
|
/** Detailed system prompt in markdown */
|
|
systemPrompt: string;
|
|
/** Suggested thinking level */
|
|
thinkingLevel: "off" | "minimal" | "low" | "medium" | "high";
|
|
/** Suggested max turns (1-500) */
|
|
maxTurns: number;
|
|
}
|
|
|
|
/** State of an agent generation session */
|
|
export interface AgentGenerationSession {
|
|
id: string;
|
|
roleDescription: string;
|
|
spec?: AgentGenerationSpec;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Start an agent generation session with a role description */
|
|
export function startAgentGeneration(role: string, projectId?: string): Promise<{ sessionId: string; roleDescription: string }> {
|
|
return api<{ sessionId: string; roleDescription: string }>(withProjectId("/agents/generate/start", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
}
|
|
|
|
/** Generate the agent specification for an existing session */
|
|
export function generateAgentSpec(sessionId: string, projectId?: string): Promise<{ spec: AgentGenerationSpec }> {
|
|
return api<{ spec: AgentGenerationSpec }>(withProjectId("/agents/generate/spec", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId }),
|
|
});
|
|
}
|
|
|
|
/** Get the current state of an agent generation session */
|
|
export function getAgentGenerationSession(sessionId: string, projectId?: string): Promise<{ session: AgentGenerationSession }> {
|
|
return api<{ session: AgentGenerationSession }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId));
|
|
}
|
|
|
|
/** Cancel and clean up an agent generation session */
|
|
export function cancelAgentGeneration(sessionId: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/agents/generate/${encodeURIComponent(sessionId)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// --- 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 201-2000 chars)
|
|
* @param provider - Optional AI model provider (e.g., "anthropic")
|
|
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
|
* @param projectId - Optional project ID for scoped settings resolution
|
|
* @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,
|
|
projectId?: string
|
|
): Promise<string> {
|
|
const url = projectId
|
|
? `/api/ai/summarize-title?projectId=${encodeURIComponent(projectId)}`
|
|
: "/api/ai/summarize-title";
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: withTokenHeader({ "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";
|
|
nodeId?: string;
|
|
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";
|
|
nodeId?: string;
|
|
cloneUrl?: string;
|
|
}
|
|
|
|
export type DockerNodeConfigInfo = DockerNodeConfig;
|
|
export type { DockerNodeConfig };
|
|
|
|
/** Node information returned by node endpoints */
|
|
export interface NodeInfo {
|
|
id: NodeConfig["id"];
|
|
name: NodeConfig["name"];
|
|
type: NodeConfig["type"];
|
|
url?: NodeConfig["url"];
|
|
apiKey?: NodeConfig["apiKey"];
|
|
status: NodeStatus;
|
|
capabilities?: NodeConfig["capabilities"];
|
|
maxConcurrent: NodeConfig["maxConcurrent"];
|
|
createdAt: NodeConfig["createdAt"];
|
|
updatedAt: NodeConfig["updatedAt"];
|
|
dockerConfig?: DockerNodeConfigInfo;
|
|
}
|
|
|
|
/** Managed Docker node information returned by docker node endpoints */
|
|
export interface DockerNodeInfo {
|
|
id: string;
|
|
nodeId: string | null;
|
|
name: string;
|
|
nodeType: "docker-managed";
|
|
imageName: string;
|
|
imageTag: string;
|
|
containerId: string | null;
|
|
status: DockerNodeStatus;
|
|
hostConfig: DockerHostConfig;
|
|
envVars: Record<string, string>;
|
|
volumeMounts: DockerVolumeMount[];
|
|
resourceSizing: DockerResourceSizing;
|
|
extraClis: DockerExtraCli[];
|
|
persistentStorage: boolean;
|
|
reachableUrl: string | null;
|
|
apiKey: string | null;
|
|
errorMessage: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ManagedDockerNodeInfo {
|
|
id: string;
|
|
nodeId?: string;
|
|
name: string;
|
|
containerId?: string;
|
|
status: string;
|
|
hostConfig: {
|
|
type: "local" | "remote";
|
|
host?: string;
|
|
context?: string;
|
|
tlsOptions?: Record<string, unknown>;
|
|
};
|
|
envVars: Record<string, string>;
|
|
reachableUrl?: string;
|
|
imageName: string;
|
|
imageTag: string;
|
|
volumeMounts: Array<{ hostPath: string; containerPath: string; readOnly?: boolean }>;
|
|
persistentStorage: boolean;
|
|
resourceSizing?: { cpuLimit?: string; memoryLimit?: string };
|
|
errorMessage?: string;
|
|
linkedNode?: NodeInfo;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ContainerStatusInfo {
|
|
running: boolean;
|
|
status: string;
|
|
startedAt?: string;
|
|
finishedAt?: string;
|
|
exitCode?: number;
|
|
error?: string;
|
|
ports?: Record<string, string>;
|
|
}
|
|
|
|
/** Node discovered over local network mDNS/DNS-SD */
|
|
export interface DiscoveredNodeInfo {
|
|
name: string;
|
|
host: string;
|
|
port: number;
|
|
nodeType: "local" | "remote";
|
|
nodeId?: string;
|
|
discoveredAt: string;
|
|
lastSeenAt: string;
|
|
}
|
|
|
|
/** Input for creating a new node */
|
|
export interface NodeCreateInput {
|
|
name: string;
|
|
type: "local" | "remote";
|
|
url?: string;
|
|
apiKey?: string;
|
|
maxConcurrent?: number;
|
|
dockerConfig?: DockerNodeConfigInfo;
|
|
}
|
|
|
|
/** Input for assigning a project path for a specific node during onboarding. */
|
|
export interface NodeProjectMappingInput {
|
|
projectId: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface RemoteNodeDiscoveredProject {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
status: "active" | "paused" | "errored" | "initializing";
|
|
isolationMode: "in-process" | "child-process";
|
|
}
|
|
|
|
export interface RemoteNodeProjectDiscoveryResult {
|
|
projects: RemoteNodeDiscoveredProject[];
|
|
}
|
|
|
|
/**
|
|
* Node onboarding payload used by dashboard UI.
|
|
*
|
|
* `projectMappings` is intentionally separate from `ProjectInfo.path` and `projects.nodeId`.
|
|
* It captures node-specific filesystem paths for selected existing projects.
|
|
*/
|
|
export interface NodeOnboardingInput extends NodeCreateInput {
|
|
projectMappings: NodeProjectMappingInput[];
|
|
}
|
|
|
|
/** Input for updating an existing node */
|
|
export type NodeUpdateInput = Partial<Pick<NodeCreateInput, "name" | "type" | "url" | "apiKey" | "maxConcurrent" | "dockerConfig">> & {
|
|
status?: NodeStatus;
|
|
capabilities?: string[];
|
|
};
|
|
|
|
/** Result from a node health check */
|
|
export interface NodeHealthCheckResult {
|
|
nodeId: string;
|
|
status: NodeStatus;
|
|
responseTimeMs?: number;
|
|
error?: string;
|
|
checkedAt: string;
|
|
}
|
|
|
|
/** Runtime metrics for a node */
|
|
export interface NodeMetrics {
|
|
nodeId: string;
|
|
activeTaskCount: number;
|
|
inFlightAgentCount: number;
|
|
uptimeMs: number;
|
|
lastActivityAt?: string;
|
|
}
|
|
|
|
/** 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, setup-wizard, normal-operation */
|
|
state: "fresh-install" | "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");
|
|
}
|
|
|
|
/** Dashboard-facing mapping contract for project availability on nodes. */
|
|
export interface ProjectNodeAvailability {
|
|
nodeId: string;
|
|
nodeName?: string;
|
|
path: string;
|
|
available: boolean;
|
|
}
|
|
|
|
/** Project info with source node metadata (added by server for remote projects). */
|
|
export interface ProjectInfoWithSource extends ProjectInfo {
|
|
/** Name of the source node (added by server for remote projects). */
|
|
_sourceNodeName?: string;
|
|
/** Normalized per-node project mappings for dashboard UI. */
|
|
nodeMappings?: ProjectNodeAvailability[];
|
|
/** Compatibility fields accepted from in-flight server rollouts. */
|
|
projectNodeMappings?: ProjectNodeAvailability[];
|
|
pathMappings?: ProjectNodeAvailability[];
|
|
}
|
|
|
|
export function hasNodeMappingsSupport(project: ProjectInfoWithSource): boolean {
|
|
return Array.isArray(project.nodeMappings)
|
|
|| Array.isArray(project.projectNodeMappings)
|
|
|| Array.isArray(project.pathMappings);
|
|
}
|
|
|
|
/** Fetch all registered projects from all nodes (local + remote) */
|
|
export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
|
|
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
|
|
}
|
|
|
|
/** Fetch all registered nodes */
|
|
export function fetchNodes(): Promise<NodeInfo[]> {
|
|
return api<NodeInfo[]>("/nodes");
|
|
}
|
|
|
|
/** Fetch discovery runtime status and active config. */
|
|
export function fetchDiscoveryStatus(): Promise<{ active: boolean; config: DiscoveryConfig | null }> {
|
|
return api<{ active: boolean; config: DiscoveryConfig | null }>("/discovery/status");
|
|
}
|
|
|
|
/** Fetch all managed Docker nodes */
|
|
export function listManagedDockerNodes(): Promise<DockerNodeInfo[]> {
|
|
return api<DockerNodeInfo[]>("/docker-nodes");
|
|
}
|
|
|
|
export function fetchManagedDockerNodes(): Promise<ManagedDockerNodeInfo[]> {
|
|
return api<ManagedDockerNodeInfo[]>("/docker/nodes");
|
|
}
|
|
|
|
export function fetchManagedDockerNode(id: string): Promise<ManagedDockerNodeInfo> {
|
|
return api<ManagedDockerNodeInfo>(`/docker/nodes/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
export function fetchManagedDockerNodeContainerStatus(id: string): Promise<ContainerStatusInfo> {
|
|
return api<ContainerStatusInfo>(`/docker/nodes/${encodeURIComponent(id)}/container-status`);
|
|
}
|
|
|
|
export function fetchDockerNodeLogs(id: string, options?: { tail?: number }): Promise<{ logs: string }> {
|
|
const params = new URLSearchParams();
|
|
if (typeof options?.tail === "number") {
|
|
params.set("tail", String(options.tail));
|
|
}
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<{ logs: string }>(`/docker/nodes/${encodeURIComponent(id)}/logs${suffix}`);
|
|
}
|
|
|
|
/** Create a managed Docker node */
|
|
export function createManagedDockerNode(input: ManagedDockerNodeInput): Promise<DockerNodeInfo> {
|
|
return api<DockerNodeInfo>("/docker-nodes", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Start local-network discovery service. */
|
|
export function startDiscovery(input?: {
|
|
broadcast?: boolean;
|
|
listen?: boolean;
|
|
port?: number;
|
|
}): Promise<{ success: boolean; config: DiscoveryConfig }> {
|
|
return api<{ success: boolean; config: DiscoveryConfig }>("/discovery/start", {
|
|
method: "POST",
|
|
body: JSON.stringify(input ?? {}),
|
|
});
|
|
}
|
|
|
|
/** Stop local-network discovery service. */
|
|
export function stopDiscovery(): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/discovery/stop", {
|
|
method: "POST",
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
/** Fetch currently discovered nodes from mDNS/DNS-SD. */
|
|
export function fetchDiscoveredNodes(): Promise<DiscoveredNodeInfo[]> {
|
|
return api<DiscoveredNodeInfo[]>("/discovery/nodes");
|
|
}
|
|
|
|
/** Register a discovered node into the central node registry. */
|
|
export function connectDiscoveredNode(input: {
|
|
name: string;
|
|
host: string;
|
|
port: number;
|
|
apiKey?: string;
|
|
}): Promise<NodeInfo> {
|
|
return api<NodeInfo>("/discovery/connect", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Register a new node */
|
|
export function registerNode(input: NodeCreateInput): Promise<NodeInfo> {
|
|
return api<NodeInfo>("/nodes", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Discover projects from a remote node before registering it. */
|
|
export function discoverRemoteNodeProjects(input: { url: string; apiKey?: string }): Promise<RemoteNodeProjectDiscoveryResult> {
|
|
return api<RemoteNodeProjectDiscoveryResult>("/nodes/discover-projects", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Fetch a single node by ID */
|
|
export function fetchNode(id: string): Promise<NodeInfo> {
|
|
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/** Fetch all project path mappings for a node */
|
|
export function fetchNodePathMappings(nodeId: string): Promise<ProjectNodePathMapping[]> {
|
|
return api<ProjectNodePathMapping[]>(`/nodes/${encodeURIComponent(nodeId)}/path-mappings`);
|
|
}
|
|
|
|
/** Update an existing node */
|
|
export function updateNode(id: string, updates: NodeUpdateInput): Promise<NodeInfo> {
|
|
return api<NodeInfo>(`/nodes/${encodeURIComponent(id)}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Fetch sanitized docker config for a node */
|
|
export function fetchDockerNodeConfig(nodeId: string): Promise<DockerNodeConfigInfo | null> {
|
|
return api<DockerNodeConfigInfo | null>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`);
|
|
}
|
|
|
|
/** Replace full docker config for a node */
|
|
export function replaceDockerNodeConfig(nodeId: string, config: DockerNodeConfig): Promise<DockerNodeConfigInfo> {
|
|
return api<DockerNodeConfigInfo>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(config),
|
|
});
|
|
}
|
|
|
|
/** Patch docker config for a node */
|
|
export function updateDockerNodeConfig(nodeId: string, config: Partial<DockerNodeConfig>): Promise<DockerNodeConfigInfo> {
|
|
return api<DockerNodeConfigInfo>(`/nodes/${encodeURIComponent(nodeId)}/docker-config`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(config),
|
|
});
|
|
}
|
|
|
|
/** Fetch docker config diff status for a node */
|
|
export function fetchDockerConfigDiff(nodeId: string): Promise<{
|
|
persistedVersion: number;
|
|
deployedVersion: number | null;
|
|
needsRecreate: boolean;
|
|
}> {
|
|
return api<{ persistedVersion: number; deployedVersion: number | null; needsRecreate: boolean }>(
|
|
`/nodes/${encodeURIComponent(nodeId)}/docker-config/diff`,
|
|
);
|
|
}
|
|
|
|
/** Unregister a node */
|
|
export function unregisterNode(id: string): Promise<void> {
|
|
return api<void>(`/nodes/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Trigger a node health check */
|
|
export async function checkNodeHealth(id: string): Promise<NodeHealthCheckResult> {
|
|
const result = await api<Partial<NodeHealthCheckResult> & { status: NodeStatus }>(`/nodes/${encodeURIComponent(id)}/health-check`, {
|
|
method: "POST",
|
|
});
|
|
|
|
return {
|
|
nodeId: result.nodeId ?? id,
|
|
status: result.status,
|
|
responseTimeMs: result.responseTimeMs,
|
|
error: result.error,
|
|
checkedAt: result.checkedAt ?? new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/** Fetch runtime metrics for a node */
|
|
export async function fetchNodeMetrics(id: string): Promise<SystemMetrics | null> {
|
|
return api<SystemMetrics | null>(`/nodes/${encodeURIComponent(id)}/metrics`);
|
|
}
|
|
|
|
/** Fetch full mesh topology state (all nodes with their metrics and known peers) */
|
|
export async function fetchMeshState(): Promise<NodeMeshState[]> {
|
|
return api<NodeMeshState[]>("/mesh/state");
|
|
}
|
|
|
|
/** 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,
|
|
nodeId?: string,
|
|
localNodeId?: string,
|
|
): Promise<BrowseDirectoryResult> {
|
|
const effectiveNodeId = nodeId && nodeId !== localNodeId ? nodeId : undefined;
|
|
const params = new URLSearchParams();
|
|
if (path) params.set("path", path);
|
|
if (showHidden) params.set("showHidden", "true");
|
|
if (effectiveNodeId) params.set("nodeId", effectiveNodeId);
|
|
const token = getAuthToken();
|
|
if (token) {
|
|
params.set("fn_token", token);
|
|
}
|
|
const qs = params.toString();
|
|
const fullPath = `/browse-directory${qs ? `?${qs}` : ""}`;
|
|
return api<BrowseDirectoryResult>(fullPath);
|
|
}
|
|
|
|
/** 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 all per-node path mappings for a project */
|
|
export function fetchProjectPathMappings(projectId: string): Promise<ProjectNodePathMapping[]> {
|
|
return api<ProjectNodePathMapping[]>(`/projects/${encodeURIComponent(projectId)}/path-mappings`);
|
|
}
|
|
|
|
/** Fetch a single project-node path mapping */
|
|
export function fetchProjectPathMapping(projectId: string, nodeId: string): Promise<ProjectNodePathMapping> {
|
|
return api<ProjectNodePathMapping>(
|
|
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
|
|
);
|
|
}
|
|
|
|
/** Create or update a project-node path mapping */
|
|
export function upsertProjectPathMapping(
|
|
projectId: string,
|
|
nodeId: string,
|
|
path: string,
|
|
): Promise<ProjectNodePathMapping> {
|
|
return api<ProjectNodePathMapping>(
|
|
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
|
|
{
|
|
method: "PUT",
|
|
body: JSON.stringify({ path }),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Remove a project-node path mapping */
|
|
export function removeProjectPathMapping(projectId: string, nodeId: string): Promise<void> {
|
|
return api<void>(
|
|
`/projects/${encodeURIComponent(projectId)}/path-mappings/${encodeURIComponent(nodeId)}`,
|
|
{
|
|
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));
|
|
}
|
|
|
|
export interface SystemStatsSnapshot {
|
|
rss: number;
|
|
heapUsed: number;
|
|
heapTotal: number;
|
|
heapLimit: number;
|
|
external: number;
|
|
arrayBuffers: number;
|
|
// Null until at least two samples are available to compute process CPU delta.
|
|
cpuPercent: number | null;
|
|
loadAvg: [number, number, number];
|
|
cpuCount: number;
|
|
systemTotalMem: number;
|
|
systemFreeMem: number;
|
|
pid: number;
|
|
nodeVersion: string;
|
|
platform: string;
|
|
}
|
|
|
|
export interface TaskStatsSnapshot {
|
|
total: number;
|
|
byColumn: Record<string, number>;
|
|
active: number;
|
|
agents: {
|
|
idle: number;
|
|
active: number;
|
|
running: number;
|
|
error: number;
|
|
};
|
|
}
|
|
|
|
export interface SystemStatsResponse {
|
|
systemStats: SystemStatsSnapshot;
|
|
taskStats: TaskStatsSnapshot;
|
|
vitestProcessCount?: number;
|
|
vitestLastAutoKillAt?: string | null;
|
|
}
|
|
|
|
export interface KillVitestResponse {
|
|
killed: number;
|
|
pids: number[];
|
|
}
|
|
|
|
export function fetchSystemStats(projectId?: string): Promise<SystemStatsResponse> {
|
|
return api<SystemStatsResponse>(withProjectId("/system-stats", projectId));
|
|
}
|
|
|
|
export function killVitestProcesses(projectId?: string): Promise<KillVitestResponse> {
|
|
return api<KillVitestResponse>(withProjectId("/kill-vitest", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
|
|
/** Update the system-wide concurrency limit shared across all projects. */
|
|
export function updateGlobalConcurrency(input: {
|
|
globalMaxConcurrent: number;
|
|
}): Promise<GlobalConcurrencyState> {
|
|
return api<GlobalConcurrencyState>("/global-concurrency", {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** 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" | "blocked";
|
|
|
|
/** Autopilot state values for mission autonomous progression */
|
|
export type AutopilotState = "inactive" | "watching" | "activating" | "completing";
|
|
|
|
/** Autopilot status for a mission */
|
|
export interface AutopilotStatus {
|
|
enabled: boolean;
|
|
state: AutopilotState;
|
|
watched: boolean;
|
|
lastActivityAt?: string;
|
|
nextScheduledCheck?: string;
|
|
}
|
|
|
|
/** Mission entity */
|
|
export interface Mission {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
status: MissionStatus;
|
|
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
|
autoAdvance?: boolean;
|
|
/** When true, enable autopilot monitoring system for this mission */
|
|
autopilotEnabled?: boolean;
|
|
/** Current autopilot runtime state */
|
|
autopilotState?: AutopilotState;
|
|
/** ISO-8601 timestamp of last autopilot activity */
|
|
lastAutopilotActivityAt?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Status summary for a mission card, computed from hierarchy */
|
|
export interface MissionSummary {
|
|
totalMilestones: number;
|
|
completedMilestones: number;
|
|
totalFeatures: number;
|
|
completedFeatures: number;
|
|
progressPercent: number;
|
|
}
|
|
|
|
/** Mission with optional status summary (returned by list endpoint) */
|
|
export type MissionWithSummary = Mission & { summary?: MissionSummary };
|
|
|
|
/** 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 with status summary */
|
|
export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]> {
|
|
return api<MissionWithSummary[]>(withProjectId("/missions", projectId));
|
|
}
|
|
|
|
/** Create a new mission */
|
|
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean }, 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));
|
|
}
|
|
|
|
/** Query options for paginated mission event logs. */
|
|
export interface MissionEventQueryOptions {
|
|
limit?: number;
|
|
offset?: number;
|
|
eventType?: MissionEventType;
|
|
}
|
|
|
|
/** Paginated mission event log response. */
|
|
export interface MissionEventsResponse {
|
|
events: MissionEvent[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
/** Fetch paginated mission observability events. */
|
|
export function fetchMissionEvents(
|
|
missionId: string,
|
|
options?: MissionEventQueryOptions,
|
|
projectId?: string,
|
|
): Promise<MissionEventsResponse> {
|
|
const query = new URLSearchParams();
|
|
if (options?.limit !== undefined) query.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) query.set("offset", String(options.offset));
|
|
if (options?.eventType !== undefined) query.set("eventType", options.eventType);
|
|
|
|
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
|
return api<MissionEventsResponse>(
|
|
withProjectId(`/missions/${encodeURIComponent(missionId)}/events${suffix}`, projectId),
|
|
);
|
|
}
|
|
|
|
/** Fetch computed mission health metrics. */
|
|
export function fetchMissionHealth(missionId: string, projectId?: string): Promise<MissionHealth> {
|
|
return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId));
|
|
}
|
|
|
|
/** Fetch health metrics for all missions in a single batched request. */
|
|
export function fetchMissionsHealth(projectId?: string): Promise<Record<string, MissionHealth>> {
|
|
return api<Record<string, MissionHealth>>(withProjectId("/missions/health", 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",
|
|
});
|
|
}
|
|
|
|
/** Triage a feature — create a task from the feature and link it */
|
|
export function triageFeature(
|
|
featureId: string,
|
|
taskTitle?: string,
|
|
taskDescription?: string,
|
|
projectId?: string,
|
|
branchOptions?: {
|
|
branchSelection?: {
|
|
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
};
|
|
branchAssignment?: { mode: "shared" | "per-task-derived" };
|
|
},
|
|
): Promise<MissionFeature> {
|
|
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ taskTitle, taskDescription, ...branchOptions }),
|
|
});
|
|
}
|
|
|
|
/** Triage all "defined" features in a slice */
|
|
export function triageAllSliceFeatures(
|
|
sliceId: string,
|
|
projectId?: string,
|
|
branchOptions?: {
|
|
branchSelection?: {
|
|
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
};
|
|
branchAssignment?: { mode: "shared" | "per-task-derived" };
|
|
},
|
|
): Promise<{ triaged: MissionFeature[]; count: number }> {
|
|
return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(branchOptions ?? {}),
|
|
});
|
|
}
|
|
|
|
// ── Contract Assertion API ─────────────────────────────────────────────────────
|
|
|
|
/** Contract assertion status */
|
|
export type MissionAssertionStatus = "pending" | "passed" | "failed" | "blocked";
|
|
|
|
/** A contract assertion represents an explicit behavioral test or requirement associated with a milestone */
|
|
export interface MissionContractAssertion {
|
|
id: string;
|
|
milestoneId: string;
|
|
title: string;
|
|
assertion: string;
|
|
status: MissionAssertionStatus;
|
|
orderIndex: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Input for creating a contract assertion */
|
|
export interface ContractAssertionCreateInput {
|
|
title: string;
|
|
assertion: string;
|
|
status?: MissionAssertionStatus;
|
|
}
|
|
|
|
/** Input for updating a contract assertion */
|
|
export interface ContractAssertionUpdateInput {
|
|
title?: string;
|
|
assertion?: string;
|
|
status?: MissionAssertionStatus;
|
|
}
|
|
|
|
/** List assertions for a milestone, ordered by orderIndex */
|
|
export function fetchAssertions(milestoneId: string, projectId?: string): Promise<MissionContractAssertion[]> {
|
|
return api<MissionContractAssertion[]>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId));
|
|
}
|
|
|
|
/** Create a new assertion for a milestone */
|
|
export function createAssertion(milestoneId: string, input: ContractAssertionCreateInput, projectId?: string): Promise<MissionContractAssertion> {
|
|
return api<MissionContractAssertion>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Reorder assertions within a milestone */
|
|
export function reorderAssertions(milestoneId: string, orderedIds: string[], projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/assertions/reorder`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ orderedIds }),
|
|
});
|
|
}
|
|
|
|
/** Get a single assertion by ID */
|
|
export function fetchAssertion(assertionId: string, projectId?: string): Promise<MissionContractAssertion> {
|
|
return api<MissionContractAssertion>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId));
|
|
}
|
|
|
|
/** Update an assertion */
|
|
export function updateAssertion(assertionId: string, updates: ContractAssertionUpdateInput, projectId?: string): Promise<MissionContractAssertion> {
|
|
return api<MissionContractAssertion>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Delete an assertion */
|
|
export function deleteAssertion(assertionId: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Link a feature to an assertion */
|
|
export function linkFeatureToAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/link`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Unlink a feature from an assertion */
|
|
export function unlinkFeatureFromAssertion(featureId: string, assertionId: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions/${encodeURIComponent(assertionId)}/unlink`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** List assertions linked to a feature */
|
|
export function fetchAssertionsForFeature(featureId: string, projectId?: string): Promise<MissionContractAssertion[]> {
|
|
return api<MissionContractAssertion[]>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/assertions`, projectId));
|
|
}
|
|
|
|
/** List features linked to an assertion */
|
|
export function fetchFeaturesForAssertion(assertionId: string, projectId?: string): Promise<MissionFeature[]> {
|
|
return api<MissionFeature[]>(withProjectId(`/missions/assertions/${encodeURIComponent(assertionId)}/features`, projectId));
|
|
}
|
|
|
|
/** Validation rollup for a milestone */
|
|
export interface MilestoneValidationRollup {
|
|
milestoneId: string;
|
|
totalAssertions: number;
|
|
passedAssertions: number;
|
|
failedAssertions: number;
|
|
blockedAssertions: number;
|
|
pendingAssertions: number;
|
|
unlinkedAssertions: number;
|
|
state: "not_started" | "needs_coverage" | "ready" | "passed" | "failed" | "blocked";
|
|
}
|
|
|
|
/** Get milestone validation rollup */
|
|
export function fetchMilestoneValidation(milestoneId: string, projectId?: string): Promise<MilestoneValidationRollup> {
|
|
return api<MilestoneValidationRollup>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/validation`, projectId));
|
|
}
|
|
|
|
/** Fetch grouped validation telemetry for a milestone */
|
|
export function fetchMilestoneValidationTelemetry(milestoneId: string, projectId?: string): Promise<MilestoneValidationTelemetry> {
|
|
return api<MilestoneValidationTelemetry>(withProjectId(`/missions/milestones/${encodeURIComponent(milestoneId)}/validation-telemetry`, projectId));
|
|
}
|
|
|
|
// ── Validation Loop API ───────────────────────────────────────────────────────
|
|
|
|
/** Loop state snapshot for a feature */
|
|
export interface MissionFeatureLoopSnapshot {
|
|
featureId: string;
|
|
feature: MissionFeature;
|
|
loopState: "idle" | "implementing" | "validating" | "needs_fix" | "passed" | "blocked";
|
|
implementationAttemptCount: number;
|
|
validatorAttemptCount: number;
|
|
lastValidatorRunId?: string;
|
|
lastValidatorStatus?: "running" | "passed" | "failed" | "blocked" | "error";
|
|
generatedFromFeatureId?: string;
|
|
generatedFromRunId?: string;
|
|
retryBudgetRemaining: number;
|
|
}
|
|
|
|
/** Validator run */
|
|
export interface MissionValidatorRun {
|
|
id: string;
|
|
featureId: string;
|
|
milestoneId: string;
|
|
sliceId: string;
|
|
status: "running" | "passed" | "failed" | "blocked" | "error";
|
|
triggerType: string;
|
|
implementationAttempt: number;
|
|
validatorAttempt: number;
|
|
summary?: string;
|
|
blockedReason?: string;
|
|
startedAt: string;
|
|
completedAt?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** Trigger validation for a feature */
|
|
export function triggerValidation(featureId: string, projectId?: string): Promise<{ runId: string; featureId: string; status: string; triggerType: string; implementationAttempt: number; validatorAttempt: number; startedAt: string }> {
|
|
return api(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validate`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Get validation loop state for a feature */
|
|
export function fetchValidationLoopState(featureId: string, projectId?: string): Promise<MissionFeatureLoopSnapshot> {
|
|
return api<MissionFeatureLoopSnapshot>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-loop`, projectId));
|
|
}
|
|
|
|
/** Paginated response wrapper for validation runs */
|
|
export interface ValidationRunsResponse {
|
|
runs: MissionValidatorRun[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
/** List validation runs for a feature */
|
|
export function fetchValidationRuns(featureId: string, options?: { limit?: number; offset?: number }, projectId?: string): Promise<MissionValidatorRun[]> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<ValidationRunsResponse>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/validation-runs${suffix}`, projectId))
|
|
.then((response) => response.runs);
|
|
}
|
|
|
|
/** Get a single validator run */
|
|
export function fetchValidationRun(runId: string, projectId?: string): Promise<MissionValidatorRun & { failures?: Array<{ id: string; assertionId: string; message?: string; expected?: string; actual?: string }> }> {
|
|
return api(withProjectId(`/missions/validation-runs/${encodeURIComponent(runId)}`, projectId));
|
|
}
|
|
|
|
/** Pause a mission (sets status to "blocked", in-flight tasks continue) */
|
|
export function pauseMission(missionId: string, projectId?: string): Promise<Mission> {
|
|
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Resume a paused mission (sets status back to "active") */
|
|
export function resumeMission(missionId: string, projectId?: string): Promise<Mission> {
|
|
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/resume`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Stop a mission (sets status to "blocked" and pauses all linked tasks) */
|
|
export function stopMission(missionId: string, projectId?: string): Promise<Mission & { pausedTaskIds: string[] }> {
|
|
return api<Mission & { pausedTaskIds: string[] }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/stop`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Start a planning mission: sets status to "active" and activates the first pending slice */
|
|
export function startMission(missionId: string, projectId?: string): Promise<MissionWithHierarchy> {
|
|
return api<MissionWithHierarchy>(withProjectId(`/missions/${encodeURIComponent(missionId)}/start`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// ── Mission Autopilot API ────────────────────────────────────────────────
|
|
|
|
/** Fetch autopilot status for a mission */
|
|
export function fetchMissionAutopilotStatus(missionId: string, projectId?: string): Promise<AutopilotStatus> {
|
|
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId));
|
|
}
|
|
|
|
/** Update autopilot settings for a mission (enable/disable) */
|
|
export function updateMissionAutopilot(missionId: string, updates: { enabled?: boolean }, projectId?: string): Promise<AutopilotStatus> {
|
|
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Manually start autopilot watching for a mission */
|
|
export function startMissionAutopilot(missionId: string, projectId?: string): Promise<AutopilotStatus> {
|
|
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/start`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Manually stop autopilot watching for a mission */
|
|
export function stopMissionAutopilot(missionId: string, projectId?: string): Promise<AutopilotStatus> {
|
|
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/stop`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// ── Mission Interview API ─────────────────────────────────────────────────
|
|
|
|
/** Mission plan types returned by the interview AI */
|
|
export interface MissionPlanFeature {
|
|
title: string;
|
|
description?: string;
|
|
acceptanceCriteria?: string;
|
|
}
|
|
|
|
export interface MissionPlanSlice {
|
|
title: string;
|
|
description?: string;
|
|
verification?: string;
|
|
features: MissionPlanFeature[];
|
|
}
|
|
|
|
export interface MissionPlanMilestone {
|
|
title: string;
|
|
description?: string;
|
|
verification?: string;
|
|
slices: MissionPlanSlice[];
|
|
}
|
|
|
|
export interface MissionPlanSummary {
|
|
missionTitle?: string;
|
|
missionDescription?: string;
|
|
milestones: MissionPlanMilestone[];
|
|
}
|
|
|
|
export type MissionInterviewResponse =
|
|
| { type: "question"; data: PlanningQuestion }
|
|
| { type: "complete"; data: MissionPlanSummary };
|
|
|
|
/** Start a mission interview session with AI streaming */
|
|
export function startMissionInterview(
|
|
missionTitle: string,
|
|
projectId?: string,
|
|
modelOverride?: { modelProvider?: string; modelId?: string },
|
|
): Promise<{ sessionId: string }> {
|
|
return api<{ sessionId: string }>(withProjectId("/missions/interview/start", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
missionTitle,
|
|
modelProvider: modelOverride?.modelProvider,
|
|
modelId: modelOverride?.modelId,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Submit a response to the current interview question */
|
|
export function respondToMissionInterview(
|
|
sessionId: string,
|
|
responses: Record<string, unknown>,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<MissionInterviewResponse> {
|
|
return api<MissionInterviewResponse>(withProjectId("/missions/interview/respond", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, responses, tabId }),
|
|
});
|
|
}
|
|
|
|
/** Retry a failed mission interview turn */
|
|
export function retryMissionInterviewSession(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ success: boolean; sessionId: string }> {
|
|
return api<{ success: boolean; sessionId: string }>(
|
|
withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId),
|
|
{
|
|
method: "POST",
|
|
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Cancel an active mission interview session */
|
|
export function cancelMissionInterview(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
|
return api<void>(withProjectId("/missions/interview/cancel", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, tabId }),
|
|
});
|
|
}
|
|
|
|
/** Create mission from completed interview */
|
|
export function createMissionFromInterview(
|
|
sessionId: string,
|
|
summary?: MissionPlanSummary,
|
|
projectId?: string,
|
|
options?: {
|
|
branch?: string;
|
|
baseBranch?: string;
|
|
branchSelection?: {
|
|
mode: "project-default" | "auto-new" | "existing" | "custom-new";
|
|
branchName?: string;
|
|
baseBranch?: string;
|
|
};
|
|
branchAssignment?: { mode: "shared" | "per-task-derived" };
|
|
},
|
|
): Promise<MissionWithHierarchy> {
|
|
return api<MissionWithHierarchy>(withProjectId("/missions/interview/create-mission", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
summary,
|
|
...(options?.branch !== undefined ? { branch: options.branch } : {}),
|
|
...(options?.baseBranch !== undefined ? { baseBranch: options.baseBranch } : {}),
|
|
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
|
...(options?.branchAssignment ? { branchAssignment: options.branchAssignment } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Connect to mission interview SSE stream and handle events */
|
|
export function connectMissionInterviewStream(
|
|
sessionId: string,
|
|
projectId: string | undefined,
|
|
handlers: {
|
|
onThinking?: (data: string) => void;
|
|
onQuestion?: (data: PlanningQuestion) => void;
|
|
onSummary?: (data: MissionPlanSummary) => void;
|
|
onError?: (data: string) => void;
|
|
onComplete?: () => void;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = buildApiUrl(withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/stream`, projectId));
|
|
let keepAlive: { stop: () => void } | null = null;
|
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
|
|
|
const stopKeepAlive = () => {
|
|
keepAlive?.stop();
|
|
keepAlive = null;
|
|
};
|
|
|
|
const resilient = createResilientEventSource(
|
|
url,
|
|
{
|
|
onOpen: () => {
|
|
stopKeepAlive();
|
|
keepAlive = startKeepAlive(sessionId, projectId);
|
|
},
|
|
onMessage: (event) => {
|
|
if (event.data.startsWith(":")) return;
|
|
},
|
|
events: {
|
|
thinking: (event) => {
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(event.data));
|
|
} catch {
|
|
handlers.onThinking?.(event.data);
|
|
}
|
|
},
|
|
question: (event) => {
|
|
try {
|
|
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
|
|
} catch (err) {
|
|
console.error("[mission-interview] Failed to parse question event:", err);
|
|
}
|
|
},
|
|
summary: (event) => {
|
|
try {
|
|
handlers.onSummary?.(JSON.parse(event.data) as MissionPlanSummary);
|
|
} catch (err) {
|
|
console.error("[mission-interview] Failed to parse summary event:", err);
|
|
}
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(event.data || "Stream error");
|
|
}
|
|
connection?.close();
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
connection?.close();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => {
|
|
stopKeepAlive();
|
|
handlers.onError?.(message);
|
|
},
|
|
},
|
|
);
|
|
|
|
connection = {
|
|
close: () => {
|
|
stopKeepAlive();
|
|
resilient.close();
|
|
},
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
|
|
return connection;
|
|
}
|
|
|
|
// ── Milestone/Slice Interview API ─────────────────────────────────────────
|
|
|
|
/** Summary type for milestone/slice interview responses */
|
|
export interface TargetInterviewSummary {
|
|
title?: string;
|
|
description?: string;
|
|
planningNotes?: string;
|
|
verification?: string;
|
|
}
|
|
|
|
/** Response from milestone/slice interview: either a question or a completed plan */
|
|
export type TargetInterviewResponse =
|
|
| { type: "question"; data: PlanningQuestion }
|
|
| { type: "complete"; data: TargetInterviewSummary };
|
|
|
|
// Helper functions for URL construction
|
|
function buildMilestoneInterviewUrl(milestoneId: string, path: string, projectId?: string): string {
|
|
return withProjectId(
|
|
`/missions/milestones/${encodeURIComponent(milestoneId)}/interview${path}`,
|
|
projectId
|
|
);
|
|
}
|
|
|
|
function buildSliceInterviewUrl(sliceId: string, path: string, projectId?: string): string {
|
|
return withProjectId(
|
|
`/missions/slices/${encodeURIComponent(sliceId)}/interview${path}`,
|
|
projectId
|
|
);
|
|
}
|
|
|
|
/** Start a milestone interview session */
|
|
export function startMilestoneInterview(
|
|
milestoneId: string,
|
|
projectId?: string,
|
|
): Promise<{ sessionId: string }> {
|
|
return api<{ sessionId: string }>(buildMilestoneInterviewUrl(milestoneId, "/start", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Submit a response to a milestone interview question */
|
|
export function respondToMilestoneInterview(
|
|
sessionId: string,
|
|
responses: Record<string, unknown>,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<TargetInterviewResponse> {
|
|
return api<TargetInterviewResponse>(buildMilestoneInterviewUrl(sessionId, "/respond", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, responses, tabId }),
|
|
});
|
|
}
|
|
|
|
/** Connect to milestone interview SSE stream and handle events */
|
|
export function connectMilestoneInterviewStream(
|
|
sessionId: string,
|
|
projectId: string | undefined,
|
|
handlers: {
|
|
onThinking?: (data: string) => void;
|
|
onQuestion?: (data: PlanningQuestion) => void;
|
|
onSummary?: (data: TargetInterviewSummary) => void;
|
|
onError?: (data: string) => void;
|
|
onComplete?: () => void;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = buildApiUrl(buildMilestoneInterviewUrl(sessionId, `/${encodeURIComponent(sessionId)}/stream`, projectId));
|
|
let keepAlive: { stop: () => void } | null = null;
|
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
|
|
|
const stopKeepAlive = () => {
|
|
keepAlive?.stop();
|
|
keepAlive = null;
|
|
};
|
|
|
|
const resilient = createResilientEventSource(
|
|
url,
|
|
{
|
|
onOpen: () => {
|
|
stopKeepAlive();
|
|
keepAlive = startKeepAlive(sessionId, projectId);
|
|
},
|
|
onMessage: (event) => {
|
|
if (event.data.startsWith(":")) return;
|
|
},
|
|
events: {
|
|
thinking: (event) => {
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(event.data));
|
|
} catch {
|
|
handlers.onThinking?.(event.data);
|
|
}
|
|
},
|
|
question: (event) => {
|
|
try {
|
|
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
|
|
} catch (err) {
|
|
console.error("[milestone-interview] Failed to parse question event:", err);
|
|
}
|
|
},
|
|
summary: (event) => {
|
|
try {
|
|
handlers.onSummary?.(JSON.parse(event.data) as TargetInterviewSummary);
|
|
} catch (err) {
|
|
console.error("[milestone-interview] Failed to parse summary event:", err);
|
|
}
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(event.data || "Stream error");
|
|
}
|
|
connection?.close();
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
connection?.close();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => {
|
|
stopKeepAlive();
|
|
handlers.onError?.(message);
|
|
},
|
|
},
|
|
);
|
|
|
|
connection = {
|
|
close: () => {
|
|
stopKeepAlive();
|
|
resilient.close();
|
|
},
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
|
|
return connection;
|
|
}
|
|
|
|
/** Apply milestone interview results to the milestone */
|
|
export function applyMilestoneInterview(
|
|
sessionId: string,
|
|
summary?: TargetInterviewSummary,
|
|
projectId?: string,
|
|
): Promise<Milestone> {
|
|
return api<Milestone>(buildMilestoneInterviewUrl(sessionId, "/apply", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, summary }),
|
|
});
|
|
}
|
|
|
|
/** Skip milestone interview and use mission context */
|
|
export function skipMilestoneInterview(
|
|
milestoneId: string,
|
|
projectId?: string,
|
|
): Promise<Milestone> {
|
|
return api<Milestone>(buildMilestoneInterviewUrl(milestoneId, "/skip", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Start a slice interview session */
|
|
export function startSliceInterview(
|
|
sliceId: string,
|
|
projectId?: string,
|
|
): Promise<{ sessionId: string }> {
|
|
return api<{ sessionId: string }>(buildSliceInterviewUrl(sliceId, "/start", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Submit a response to a slice interview question */
|
|
export function respondToSliceInterview(
|
|
sessionId: string,
|
|
responses: Record<string, unknown>,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<TargetInterviewResponse> {
|
|
return api<TargetInterviewResponse>(buildSliceInterviewUrl(sessionId, "/respond", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, responses, tabId }),
|
|
});
|
|
}
|
|
|
|
/** Connect to slice interview SSE stream and handle events */
|
|
export function connectSliceInterviewStream(
|
|
sessionId: string,
|
|
projectId: string | undefined,
|
|
handlers: {
|
|
onThinking?: (data: string) => void;
|
|
onQuestion?: (data: PlanningQuestion) => void;
|
|
onSummary?: (data: TargetInterviewSummary) => void;
|
|
onError?: (data: string) => void;
|
|
onComplete?: () => void;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
},
|
|
options?: { maxReconnectAttempts?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = buildApiUrl(buildSliceInterviewUrl(sessionId, `/${encodeURIComponent(sessionId)}/stream`, projectId));
|
|
let keepAlive: { stop: () => void } | null = null;
|
|
let connection: { close: () => void; isConnected: () => boolean } | null = null;
|
|
|
|
const stopKeepAlive = () => {
|
|
keepAlive?.stop();
|
|
keepAlive = null;
|
|
};
|
|
|
|
const resilient = createResilientEventSource(
|
|
url,
|
|
{
|
|
onOpen: () => {
|
|
stopKeepAlive();
|
|
keepAlive = startKeepAlive(sessionId, projectId);
|
|
},
|
|
onMessage: (event) => {
|
|
if (event.data.startsWith(":")) return;
|
|
},
|
|
events: {
|
|
thinking: (event) => {
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(event.data));
|
|
} catch {
|
|
handlers.onThinking?.(event.data);
|
|
}
|
|
},
|
|
question: (event) => {
|
|
try {
|
|
handlers.onQuestion?.(JSON.parse(event.data) as PlanningQuestion);
|
|
} catch (err) {
|
|
console.error("[slice-interview] Failed to parse question event:", err);
|
|
}
|
|
},
|
|
summary: (event) => {
|
|
try {
|
|
handlers.onSummary?.(JSON.parse(event.data) as TargetInterviewSummary);
|
|
} catch (err) {
|
|
console.error("[slice-interview] Failed to parse summary event:", err);
|
|
}
|
|
},
|
|
error: (event) => {
|
|
try {
|
|
const parsed = JSON.parse(event.data);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(event.data || "Stream error");
|
|
}
|
|
connection?.close();
|
|
},
|
|
complete: () => {
|
|
handlers.onComplete?.();
|
|
connection?.close();
|
|
},
|
|
},
|
|
},
|
|
{
|
|
maxReconnectAttempts: options?.maxReconnectAttempts,
|
|
onConnectionStateChange: handlers.onConnectionStateChange,
|
|
onFatalError: (message) => {
|
|
stopKeepAlive();
|
|
handlers.onError?.(message);
|
|
},
|
|
},
|
|
);
|
|
|
|
connection = {
|
|
close: () => {
|
|
stopKeepAlive();
|
|
resilient.close();
|
|
},
|
|
isConnected: resilient.isConnected,
|
|
};
|
|
|
|
return connection;
|
|
}
|
|
|
|
/** Apply slice interview results to the slice */
|
|
export function applySliceInterview(
|
|
sessionId: string,
|
|
summary?: TargetInterviewSummary,
|
|
projectId?: string,
|
|
): Promise<Slice> {
|
|
return api<Slice>(buildSliceInterviewUrl(sessionId, "/apply", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ sessionId, summary }),
|
|
});
|
|
}
|
|
|
|
/** Skip slice interview and use mission context */
|
|
export function skipSliceInterview(
|
|
sliceId: string,
|
|
projectId?: string,
|
|
): Promise<Slice> {
|
|
return api<Slice>(buildSliceInterviewUrl(sliceId, "/skip", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Preview enriched description for a feature before triage */
|
|
export async function previewEnrichedDescription(
|
|
featureId: string,
|
|
projectId?: string,
|
|
): Promise<{ description: string }> {
|
|
try {
|
|
return await api<{ description: string }>(
|
|
withProjectId(`/missions/features/${encodeURIComponent(featureId)}/preview-description`, projectId),
|
|
{
|
|
method: "POST",
|
|
}
|
|
);
|
|
} catch {
|
|
// If endpoint doesn't exist, throw to trigger fallback
|
|
throw new Error("Preview endpoint not available");
|
|
}
|
|
}
|
|
|
|
// ── Todo API ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Fetch all todo lists with their items */
|
|
export function fetchTodoLists(projectId?: string): Promise<TodoListWithItems[]> {
|
|
return api<TodoListWithItems[]>(withProjectId("/todos", projectId));
|
|
}
|
|
|
|
/** Create a new todo list */
|
|
export function createTodoList(title: string, projectId?: string): Promise<TodoList> {
|
|
const input: TodoListCreateInput = { title };
|
|
return api<TodoList>(withProjectId("/todos", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Update a todo list title */
|
|
export function updateTodoList(id: string, title: string, projectId?: string): Promise<TodoList> {
|
|
const updates: TodoListUpdateInput = { title };
|
|
return api<TodoList>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Delete a todo list and all its items */
|
|
export function deleteTodoList(id: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Create a new item in a todo list */
|
|
export function createTodoItem(listId: string, text: string, projectId?: string): Promise<TodoItem> {
|
|
const input: TodoItemCreateInput = { text };
|
|
return api<TodoItem>(withProjectId(`/todos/${encodeURIComponent(listId)}/items`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Update a todo item (text and/or completed) */
|
|
export function updateTodoItem(
|
|
id: string,
|
|
data: { text?: string; completed?: boolean },
|
|
projectId?: string
|
|
): Promise<TodoItem> {
|
|
const updates: TodoItemUpdateInput = data;
|
|
return api<TodoItem>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Delete a todo item */
|
|
export function deleteTodoItem(id: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Reorder items within a todo list */
|
|
export function reorderTodoItems(listId: string, itemIds: string[], projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/todos/${encodeURIComponent(listId)}/items/reorder`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ itemIds }),
|
|
});
|
|
}
|
|
|
|
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
|
|
|
export interface AiSessionSummary {
|
|
id: string;
|
|
type: "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
|
|
status: "draft" | "generating" | "awaiting_input" | "complete" | "error";
|
|
title: string;
|
|
/** Server-derived preview of the in-progress initialPlan; only set for draft planning sessions. */
|
|
preview?: string;
|
|
projectId: string | null;
|
|
lockedByTab: string | null;
|
|
updatedAt: string;
|
|
archived?: boolean;
|
|
}
|
|
|
|
export interface ConversationHistoryEntry {
|
|
question?: PlanningQuestion;
|
|
response?: Record<string, unknown>;
|
|
thinkingOutput?: string;
|
|
}
|
|
|
|
export interface AiSessionDetail extends AiSessionSummary {
|
|
inputPayload: string;
|
|
conversationHistory: string;
|
|
currentQuestion: string | null;
|
|
result: string | null;
|
|
thinkingOutput: string;
|
|
error: string | null;
|
|
createdAt: string;
|
|
lockedAt: string | null;
|
|
}
|
|
|
|
export function parseConversationHistory(raw: string): ConversationHistoryEntry[] {
|
|
if (!raw) return [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function fetchAiSessions(
|
|
projectId?: string,
|
|
options?: { includeCompleted?: boolean; includeArchived?: boolean },
|
|
): Promise<AiSessionSummary[]> {
|
|
const search = new URLSearchParams();
|
|
if (projectId) search.set("projectId", projectId);
|
|
if (options?.includeCompleted) search.set("includeCompleted", "1");
|
|
if (options?.includeArchived) search.set("includeArchived", "1");
|
|
const qs = search.toString();
|
|
const res = await fetch(buildApiUrl(`/ai-sessions${qs ? `?${qs}` : ""}`), {
|
|
headers: withTokenHeader(),
|
|
});
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
return data.sessions ?? [];
|
|
}
|
|
|
|
export async function archiveAiSession(id: string): Promise<void> {
|
|
return api<void>(`/ai-sessions/${encodeURIComponent(id)}/archive`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export async function unarchiveAiSession(id: string): Promise<void> {
|
|
return api<void>(`/ai-sessions/${encodeURIComponent(id)}/unarchive`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export async function fetchAiSession(id: string): Promise<AiSessionDetail | null> {
|
|
const res = await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
|
|
headers: withTokenHeader(),
|
|
});
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}
|
|
|
|
export async function acquireSessionLock(
|
|
sessionId: string,
|
|
tabId: string,
|
|
): Promise<{ acquired: boolean; currentHolder: string | null }> {
|
|
const result = await api<{ acquired: boolean; currentHolder?: string | null }>(
|
|
`/ai-sessions/${encodeURIComponent(sessionId)}/lock`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ tabId }),
|
|
},
|
|
);
|
|
|
|
return {
|
|
acquired: result.acquired,
|
|
currentHolder: result.currentHolder ?? null,
|
|
};
|
|
}
|
|
|
|
export function releaseSessionLock(sessionId: string, tabId: string): Promise<void> {
|
|
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ tabId }),
|
|
});
|
|
}
|
|
|
|
export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise<void> {
|
|
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ tabId }),
|
|
});
|
|
}
|
|
|
|
export async function deleteAiSession(id: string): Promise<void> {
|
|
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), {
|
|
method: "DELETE",
|
|
headers: withTokenHeader(),
|
|
});
|
|
}
|
|
|
|
export function pingSession(sessionId: string, projectId?: string): Promise<{ ok: boolean }> {
|
|
return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/ping`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export function updatePlanningSessionDraft(
|
|
sessionId: string,
|
|
draft: { initialPlan: string; modelProvider?: string; modelId?: string },
|
|
projectId?: string,
|
|
): Promise<{ ok: boolean }> {
|
|
return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/draft`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(draft),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ask the server to (re)generate the sidebar title for a draft planning
|
|
* session from its persisted initialPlan. Server-side is idempotent and
|
|
* a no-op once the session has been started, so callers can fire-and-
|
|
* forget on textarea blur and modal close.
|
|
*/
|
|
export function summarizePlanningDraftTitle(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
): Promise<{ title: string | null }> {
|
|
return api<{ title: string | null }>(
|
|
withProjectId(`/planning/${encodeURIComponent(sessionId)}/summarize-draft-title`, projectId),
|
|
{ method: "POST" },
|
|
);
|
|
}
|
|
|
|
// ── Messages API ──────────────────────────────────────────────────────────
|
|
|
|
/** Response shape for GET /messages/inbox */
|
|
export interface InboxResponse {
|
|
messages: Message[];
|
|
total: number;
|
|
unreadCount: number;
|
|
}
|
|
|
|
/** Response shape for GET /messages/outbox */
|
|
export interface OutboxResponse {
|
|
messages: Message[];
|
|
total: number;
|
|
}
|
|
|
|
/** Response shape for GET /messages/unread-count */
|
|
export interface UnreadCountResponse {
|
|
unreadCount: number;
|
|
pendingApprovalCount?: number;
|
|
}
|
|
|
|
/** Response shape for POST /messages/read-all */
|
|
export interface MarkAllReadResponse {
|
|
markedAsRead: number;
|
|
}
|
|
|
|
/** Response shape for GET /agents/:id/mailbox */
|
|
export interface AgentMailboxResponse {
|
|
ownerId: string;
|
|
ownerType: ParticipantType;
|
|
unreadCount: number;
|
|
lastMessage?: Message;
|
|
messages: Message[]; // Backward compat alias for inbox
|
|
inbox: Message[];
|
|
outbox: Message[];
|
|
}
|
|
|
|
/** Input for sending a message via the dashboard */
|
|
export interface SendMessageInput {
|
|
toId: string;
|
|
toType: ParticipantType;
|
|
content: string;
|
|
type: MessageType;
|
|
metadata?: MessageMetadata;
|
|
wakeImmediately?: boolean;
|
|
}
|
|
|
|
export interface ApprovalRequestSummary {
|
|
id: string;
|
|
status: ApprovalRequestStatus;
|
|
actionCategory: string;
|
|
actionSummary: string;
|
|
agentId: string;
|
|
taskId?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
decidedAt?: string;
|
|
decidedBy?: string;
|
|
}
|
|
|
|
export interface ApprovalRequestDetail extends ApprovalRequestSummary {
|
|
requester: {
|
|
actorId: string;
|
|
actorType: "agent" | "user" | "system";
|
|
actorName: string;
|
|
};
|
|
runId?: string;
|
|
requestedAt: string;
|
|
completedAt?: string;
|
|
targetAction: {
|
|
category: string;
|
|
action: string;
|
|
summary: string;
|
|
resourceType: string;
|
|
resourceId: string;
|
|
context?: Record<string, unknown>;
|
|
};
|
|
history: Array<{
|
|
id: string;
|
|
eventType: string;
|
|
actor: {
|
|
actorId: string;
|
|
actorType: "agent" | "user" | "system";
|
|
actorName: string;
|
|
};
|
|
note?: string;
|
|
createdAt: string;
|
|
}>;
|
|
}
|
|
|
|
export interface ApprovalListResponse {
|
|
requests: ApprovalRequestSummary[];
|
|
total: number;
|
|
pendingCount: number;
|
|
}
|
|
|
|
/** Fetch inbox messages for the current user. */
|
|
export function fetchInbox(
|
|
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType },
|
|
projectId?: string,
|
|
): Promise<InboxResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
|
if (options?.unreadOnly) params.set("unreadOnly", "true");
|
|
if (options?.type) params.set("type", options.type);
|
|
if (projectId) params.set("projectId", projectId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<InboxResponse>(`/messages/inbox${query}`);
|
|
}
|
|
|
|
/** Fetch sent messages for the current user. */
|
|
export function fetchOutbox(
|
|
options?: { limit?: number; offset?: number; type?: MessageType },
|
|
projectId?: string,
|
|
): Promise<OutboxResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
|
if (options?.type) params.set("type", options.type);
|
|
if (projectId) params.set("projectId", projectId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<OutboxResponse>(`/messages/outbox${query}`);
|
|
}
|
|
|
|
/** Fetch unread message count (lightweight, for header badge). */
|
|
export function fetchUnreadCount(projectId?: string): Promise<UnreadCountResponse> {
|
|
return api<UnreadCountResponse>(withProjectId("/messages/unread-count", projectId));
|
|
}
|
|
|
|
/** Fetch a single message by ID. */
|
|
export function fetchMessage(id: string, projectId?: string): Promise<Message> {
|
|
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
/** Send a new message. */
|
|
export function sendMessage(input: SendMessageInput, projectId?: string): Promise<Message> {
|
|
return api<Message>(withProjectId("/messages", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Mark a specific message as read. */
|
|
export function markMessageRead(id: string, projectId?: string): Promise<Message> {
|
|
return api<Message>(withProjectId(`/messages/${encodeURIComponent(id)}/read`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Mark all inbox messages as read. */
|
|
export function markAllMessagesRead(projectId?: string): Promise<MarkAllReadResponse> {
|
|
return api<MarkAllReadResponse>(withProjectId("/messages/read-all", projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Delete a message. */
|
|
export function deleteMessage(id: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/messages/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Fetch conversation between current user and a specific participant. */
|
|
export function fetchConversation(
|
|
participantId: string,
|
|
participantType: ParticipantType,
|
|
projectId?: string,
|
|
): Promise<Message[]> {
|
|
const path = `/messages/conversation/${encodeURIComponent(participantType)}/${encodeURIComponent(participantId)}`;
|
|
return api<Message[]>(withProjectId(path, projectId));
|
|
}
|
|
|
|
/** Fetch an agent's mailbox (admin read-only view). */
|
|
export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<AgentMailboxResponse> {
|
|
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
|
|
}
|
|
|
|
export function fetchApprovals(
|
|
options?: { status?: ApprovalRequestStatus; limit?: number; offset?: number },
|
|
projectId?: string,
|
|
): Promise<ApprovalListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options?.status) params.set("status", options.status);
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
|
if (projectId) params.set("projectId", projectId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<ApprovalListResponse>(`/approvals${query}`);
|
|
}
|
|
|
|
export function fetchApprovalDetail(id: string, projectId?: string): Promise<ApprovalRequestDetail> {
|
|
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
export function decideApproval(
|
|
id: string,
|
|
input: { decision: "approve" | "deny"; comment?: string },
|
|
projectId?: string,
|
|
): Promise<ApprovalRequestDetail> {
|
|
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}/decision`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Fetch reflection history for an agent. */
|
|
export function fetchAgentReflections(agentId: string, limit?: number, projectId?: string): Promise<AgentReflection[]> {
|
|
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<AgentReflection[]>(`/agents/${encodeURIComponent(agentId)}/reflections${query}`);
|
|
}
|
|
|
|
/** Fetch the most recent reflection for an agent. */
|
|
export function fetchAgentReflection(agentId: string, projectId?: string): Promise<AgentReflection> {
|
|
return api<AgentReflection>(withProjectId(`/agents/${encodeURIComponent(agentId)}/reflections/latest`, projectId));
|
|
}
|
|
|
|
/** Trigger a manual reflection for an agent. */
|
|
export function triggerAgentReflection(agentId: string, projectId?: string): Promise<AgentReflection | null> {
|
|
return api<AgentReflection | null>(withProjectId(`/agents/${encodeURIComponent(agentId)}/reflections`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Fetch aggregated performance summary for an agent. */
|
|
export function fetchAgentPerformance(agentId: string, windowMs?: number, projectId?: string): Promise<AgentPerformanceSummary> {
|
|
const params = new URLSearchParams();
|
|
if (windowMs !== undefined) params.set("windowMs", String(windowMs));
|
|
if (projectId) params.set("projectId", projectId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<AgentPerformanceSummary>(`/agents/${encodeURIComponent(agentId)}/performance${query}`);
|
|
}
|
|
|
|
/** Fetch ratings for an agent */
|
|
export function fetchAgentRatings(
|
|
agentId: string,
|
|
options?: { limit?: number; category?: string },
|
|
projectId?: string,
|
|
): Promise<AgentRating[]> {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options?.category) params.set("category", options.category);
|
|
if (projectId) params.set("projectId", projectId);
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<AgentRating[]>(`/agents/${encodeURIComponent(agentId)}/ratings${query}`);
|
|
}
|
|
|
|
/** Add a rating for an agent */
|
|
export function addAgentRating(
|
|
agentId: string,
|
|
input: AgentRatingInput,
|
|
projectId?: string,
|
|
): Promise<AgentRating> {
|
|
return api<AgentRating>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Fetch rating summary for an agent */
|
|
export function fetchAgentRatingSummary(agentId: string, projectId?: string): Promise<AgentRatingSummary> {
|
|
return api<AgentRatingSummary>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/summary`, projectId));
|
|
}
|
|
|
|
/** Delete a specific rating */
|
|
export function deleteAgentRating(agentId: string, ratingId: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}/ratings/${encodeURIComponent(ratingId)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
// ── Agent Budget API ──────────────────────────────────────────────────────
|
|
|
|
/** Fetch budget status for an agent */
|
|
export function fetchAgentBudgetStatus(agentId: string, projectId?: string): Promise<AgentBudgetStatus> {
|
|
return api<AgentBudgetStatus>(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget`, projectId));
|
|
}
|
|
|
|
/** Reset budget usage for an agent */
|
|
export function resetAgentBudget(agentId: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/agents/${encodeURIComponent(agentId)}/budget/reset`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// ── Plugin Management ────────────────────────────────────────────────────────
|
|
|
|
/** Fetch all installed plugins */
|
|
export async function fetchPlugins(projectId?: string): Promise<PluginInstallation[]> {
|
|
return api<PluginInstallation[]>(withProjectId("/plugins", projectId));
|
|
}
|
|
|
|
/** Fetch a single plugin by ID */
|
|
export async function fetchPluginDetail(id: string, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
/** Install a plugin from local path or npm package */
|
|
export async function installPlugin(
|
|
source: { path: string; aiScanOnLoad?: boolean } | { package: string; aiScanOnLoad?: boolean },
|
|
projectId?: string,
|
|
): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId("/plugins", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ mode: "install", ...source }),
|
|
});
|
|
}
|
|
|
|
/** Enable a plugin */
|
|
export async function enablePlugin(id: string, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}/enable`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Disable a plugin */
|
|
export async function disablePlugin(id: string, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}/disable`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Uninstall a plugin */
|
|
export async function uninstallPlugin(id: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Fetch plugin settings */
|
|
export async function fetchPluginSettings(id: string, projectId?: string): Promise<Record<string, unknown>> {
|
|
return api<Record<string, unknown>>(withProjectId(`/plugins/${encodeURIComponent(id)}/settings`, projectId));
|
|
}
|
|
|
|
/** Update plugin settings */
|
|
export async function updatePluginSettings(
|
|
id: string,
|
|
settings: Record<string, unknown>,
|
|
projectId?: string,
|
|
): Promise<Record<string, unknown>> {
|
|
return api<Record<string, unknown>>(withProjectId(`/plugins/${encodeURIComponent(id)}/settings`, projectId), {
|
|
method: "PUT",
|
|
body: JSON.stringify({ settings }),
|
|
});
|
|
}
|
|
|
|
export type PluginSetupStatusResponse =
|
|
| { hasSetup: false }
|
|
| ({ hasSetup: true } & PluginSetupCheckResult)
|
|
| {
|
|
hasSetup: true;
|
|
setupCheckDeferred: true;
|
|
deferredReason: "plugin-not-started";
|
|
pluginState: PluginInstallation["state"];
|
|
};
|
|
|
|
/** Fetch plugin setup status */
|
|
export async function fetchPluginSetupStatus(id: string, projectId?: string): Promise<PluginSetupStatusResponse> {
|
|
return api<PluginSetupStatusResponse>(withProjectId(`/plugins/${encodeURIComponent(id)}/setup-status`, projectId));
|
|
}
|
|
|
|
/** Trigger plugin setup install hook */
|
|
export async function installPluginSetup(id: string, projectId?: string): Promise<{ success: boolean; error?: string }> {
|
|
return api<{ success: boolean; error?: string }>(withProjectId(`/plugins/${encodeURIComponent(id)}/setup/install`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Reload a running plugin with updated code */
|
|
export async function reloadPlugin(id: string, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}/reload`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Update plugin security-scan configuration */
|
|
export async function updatePlugin(id: string, updates: { aiScanOnLoad: boolean }, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Trigger plugin rescan + reload flow */
|
|
export async function rescanPlugin(id: string, projectId?: string): Promise<PluginInstallation> {
|
|
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}/rescan`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** A UI slot entry returned by GET /api/plugins/ui-slots */
|
|
export interface PluginUiSlotEntry {
|
|
pluginId: string;
|
|
slot: PluginUiSlotDefinition;
|
|
}
|
|
|
|
/** A structured UI contribution entry returned by GET /api/plugins/ui-contributions */
|
|
export interface PluginUiContributionEntry {
|
|
pluginId: string;
|
|
contribution: PluginUiContributionDefinition;
|
|
}
|
|
|
|
/** A dashboard view entry returned by GET /api/plugins/dashboard-views */
|
|
export interface PluginDashboardViewEntry {
|
|
pluginId: string;
|
|
view: PluginDashboardViewDefinition;
|
|
}
|
|
|
|
/** Plugin runtime metadata returned by GET /api/plugins/runtimes */
|
|
export interface PluginRuntimeInfo {
|
|
pluginId: string;
|
|
runtimeId: string;
|
|
name: string;
|
|
description?: string;
|
|
version?: string;
|
|
}
|
|
|
|
/** Fetch all UI slot definitions from active plugins */
|
|
export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSlotEntry[]> {
|
|
return api<PluginUiSlotEntry[]>(withProjectId("/plugins/ui-slots", projectId));
|
|
}
|
|
|
|
|
|
/** Fetch all structured UI contributions from active plugins */
|
|
export async function fetchPluginUiContributions(projectId?: string): Promise<PluginUiContributionEntry[]> {
|
|
return api<PluginUiContributionEntry[]>(withProjectId("/plugins/ui-contributions", projectId));
|
|
}
|
|
|
|
/** Fetch all top-level dashboard view definitions from active plugins */
|
|
export async function fetchPluginDashboardViews(projectId?: string): Promise<PluginDashboardViewEntry[]> {
|
|
return api<PluginDashboardViewEntry[]>(withProjectId("/plugins/dashboard-views", projectId));
|
|
}
|
|
|
|
/** Fetch all plugin runtime metadata from active plugins */
|
|
export async function fetchPluginRuntimes(projectId?: string): Promise<PluginRuntimeInfo[]> {
|
|
return api<PluginRuntimeInfo[]>(withProjectId("/plugins/runtimes", projectId));
|
|
}
|
|
|
|
// ── Skills Management ─────────────────────────────────────────────────────────
|
|
|
|
/** Fetch all discovered skills with their enabled state */
|
|
export async function fetchDiscoveredSkills(projectId?: string): Promise<DiscoveredSkill[]> {
|
|
const response = await api<{ skills: DiscoveredSkill[] }>(withProjectId("/skills/discovered", projectId));
|
|
return response.skills;
|
|
}
|
|
|
|
/** Toggle a skill's enabled/disabled state */
|
|
export async function toggleExecutionSkill(
|
|
skillId: string,
|
|
enabled: boolean,
|
|
projectId?: string,
|
|
): Promise<ToggleSkillResult> {
|
|
return api<ToggleSkillResult>(withProjectId("/skills/execution", projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ skillId, enabled }),
|
|
});
|
|
}
|
|
|
|
/** Fetch the skills.sh catalog */
|
|
export async function fetchSkillsCatalog(
|
|
query?: string,
|
|
limit?: number,
|
|
projectId?: string,
|
|
): Promise<CatalogFetchResult> {
|
|
const params = new URLSearchParams();
|
|
if (query) params.set("q", query);
|
|
if (limit !== undefined) params.set("limit", String(limit));
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<CatalogFetchResult>(withProjectId(`/skills/catalog${suffix}`, projectId));
|
|
}
|
|
|
|
/** Fetch the contents of a skill's SKILL.md file */
|
|
export async function fetchSkillContent(skillId: string, projectId?: string): Promise<SkillContent> {
|
|
const response = await api<{ content: SkillContent }>(
|
|
withProjectId(`/skills/${encodeURIComponent(skillId)}/content`, projectId)
|
|
);
|
|
return response.content;
|
|
}
|
|
|
|
// ── Chat API ─────────────────────────────────────────────────────────────────
|
|
|
|
// EnrichedChatSession is imported from @fusion/core above
|
|
|
|
export interface ChatSessionListResponse {
|
|
sessions: EnrichedChatSession[];
|
|
}
|
|
|
|
export interface ChatSessionResponse {
|
|
session: EnrichedChatSession;
|
|
}
|
|
|
|
export interface ChatMessageListResponse {
|
|
messages: ChatMessage[];
|
|
}
|
|
|
|
export interface ChatRoomListResponse {
|
|
rooms: ChatRoom[];
|
|
}
|
|
|
|
export interface ChatRoomResponse {
|
|
room: ChatRoom;
|
|
members?: ChatRoomMember[];
|
|
}
|
|
|
|
export interface ChatRoomMembersResponse {
|
|
members: ChatRoomMember[];
|
|
}
|
|
|
|
export interface ChatRoomMessageListResponse {
|
|
messages: ChatRoomMessage[];
|
|
}
|
|
|
|
export interface ChatRoomMessageResponse {
|
|
message: ChatRoomMessage;
|
|
}
|
|
|
|
/** Fetch all chat sessions for a project */
|
|
export function fetchChatSessions(projectId?: string, status?: string): Promise<ChatSessionListResponse> {
|
|
const search = new URLSearchParams();
|
|
if (projectId) search.set("projectId", projectId);
|
|
if (status) search.set("status", status);
|
|
const qs = search.toString();
|
|
return api<ChatSessionListResponse>(`/chat/sessions${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export interface ChatSessionResumeLookupInput {
|
|
agentId: string;
|
|
modelProvider?: string;
|
|
modelId?: string;
|
|
}
|
|
|
|
/**
|
|
* Fetch the most relevant active session for quick-chat resume semantics.
|
|
* Returns at most one session for the provided target.
|
|
*/
|
|
export async function fetchResumeChatSession(
|
|
input: ChatSessionResumeLookupInput,
|
|
projectId?: string,
|
|
): Promise<{ session: EnrichedChatSession | null }> {
|
|
const normalizedAgentId = input.agentId.trim();
|
|
if (!normalizedAgentId) {
|
|
throw new Error("agentId is required");
|
|
}
|
|
|
|
const normalizedProvider = input.modelProvider?.trim();
|
|
const normalizedModelId = input.modelId?.trim();
|
|
|
|
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
|
|
throw new Error("Both modelProvider and modelId must be provided together, or neither should be provided");
|
|
}
|
|
|
|
const search = new URLSearchParams();
|
|
search.set("lookup", "resume");
|
|
search.set("agentId", normalizedAgentId);
|
|
if (projectId) search.set("projectId", projectId);
|
|
if (normalizedProvider && normalizedModelId) {
|
|
search.set("modelProvider", normalizedProvider);
|
|
search.set("modelId", normalizedModelId);
|
|
}
|
|
|
|
const data = await api<ChatSessionListResponse>(`/chat/sessions?${search.toString()}`);
|
|
return { session: data.sessions[0] ?? null };
|
|
}
|
|
|
|
/** Create a new chat session */
|
|
export function createChatSession(
|
|
input: { agentId: string; title?: string; modelProvider?: string; modelId?: string },
|
|
projectId?: string,
|
|
): Promise<ChatSessionResponse> {
|
|
return api<ChatSessionResponse>(withProjectId("/chat/sessions", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
/** Fetch a single chat session */
|
|
export function fetchChatSession(id: string, projectId?: string): Promise<ChatSessionResponse> {
|
|
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
/** Update a chat session (title, status) */
|
|
export function updateChatSession(
|
|
id: string,
|
|
updates: { title?: string; status?: string },
|
|
projectId?: string,
|
|
): Promise<ChatSessionResponse> {
|
|
return api<ChatSessionResponse>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/** Delete a chat session */
|
|
export function deleteChatSession(id: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/** Fetch messages for a chat session */
|
|
export function fetchChatMessages(
|
|
sessionId: string,
|
|
opts?: { limit?: number; offset?: number; before?: string },
|
|
projectId?: string,
|
|
): Promise<ChatMessageListResponse> {
|
|
const search = new URLSearchParams();
|
|
if (opts?.limit !== undefined) search.set("limit", String(opts.limit));
|
|
if (opts?.offset !== undefined) search.set("offset", String(opts.offset));
|
|
if (opts?.before) search.set("before", opts.before);
|
|
const qs = search.toString();
|
|
return api<ChatMessageListResponse>(
|
|
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ""}`, projectId),
|
|
);
|
|
}
|
|
|
|
/** Delete a specific message from a chat session */
|
|
export function deleteChatMessage(
|
|
sessionId: string,
|
|
messageId: string,
|
|
projectId?: string,
|
|
): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(
|
|
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, projectId),
|
|
{
|
|
method: "DELETE",
|
|
},
|
|
);
|
|
}
|
|
|
|
export function fetchChatRooms(
|
|
options: { status?: string; agentId?: string } = {},
|
|
projectId?: string,
|
|
): Promise<ChatRoomListResponse> {
|
|
const search = new URLSearchParams();
|
|
if (projectId) search.set("projectId", projectId);
|
|
if (options.status) search.set("status", options.status);
|
|
if (options.agentId) search.set("agentId", options.agentId);
|
|
const qs = search.toString();
|
|
return api<ChatRoomListResponse>(`/chat/rooms${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
export function fetchChatRoom(id: string, projectId?: string): Promise<ChatRoomResponse> {
|
|
return api<ChatRoomResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
export function createChatRoom(
|
|
input: { name: string; description?: string | null; createdBy?: string | null; memberAgentIds?: string[] },
|
|
projectId?: string,
|
|
): Promise<ChatRoomResponse> {
|
|
const body = { ...input, ...(projectId ? { projectId } : {}) };
|
|
return api<ChatRoomResponse>(withProjectId("/chat/rooms", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
export function updateChatRoom(
|
|
id: string,
|
|
updates: { name?: string; description?: string | null; status?: "active" | "archived" },
|
|
projectId?: string,
|
|
): Promise<{ room: ChatRoom }> {
|
|
return api<{ room: ChatRoom }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
export function deleteChatRoom(id: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export function fetchChatRoomMembers(id: string, projectId?: string): Promise<ChatRoomMembersResponse> {
|
|
return api<ChatRoomMembersResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members`, projectId));
|
|
}
|
|
|
|
export function addChatRoomMember(
|
|
id: string,
|
|
input: { agentId: string; role?: "owner" | "member" },
|
|
projectId?: string,
|
|
): Promise<{ member: ChatRoomMember }> {
|
|
return api<{ member: ChatRoomMember }>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function removeChatRoomMember(id: string, agentId: string, projectId?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(
|
|
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/members/${encodeURIComponent(agentId)}`, projectId),
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
export function fetchChatRoomMessages(
|
|
id: string,
|
|
opts?: { limit?: number; offset?: number; before?: string },
|
|
projectId?: string,
|
|
): Promise<ChatRoomMessageListResponse> {
|
|
const search = new URLSearchParams();
|
|
if (opts?.limit !== undefined) search.set("limit", String(opts.limit));
|
|
if (opts?.offset !== undefined) search.set("offset", String(opts.offset));
|
|
if (opts?.before) search.set("before", opts.before);
|
|
const qs = search.toString();
|
|
return api<ChatRoomMessageListResponse>(
|
|
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages${qs ? `?${qs}` : ""}`, projectId),
|
|
);
|
|
}
|
|
|
|
export function postChatRoomMessage(
|
|
id: string,
|
|
input: { content: string; senderAgentId?: null; mentions?: string[]; attachments?: File[] | ChatAttachment[] },
|
|
projectId?: string,
|
|
): Promise<ChatRoomMessageResponse> {
|
|
return api<ChatRoomMessageResponse>(withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function deleteChatRoomMessage(
|
|
id: string,
|
|
messageId: string,
|
|
projectId?: string,
|
|
): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(
|
|
withProjectId(`/chat/rooms/${encodeURIComponent(id)}/messages/${encodeURIComponent(messageId)}`, projectId),
|
|
{ method: "DELETE" },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Room POST /messages in FN-3808 is persist-only (201 JSON response).
|
|
* Do not add streamChatRoomResponse until FN-3810 introduces AI invocation/streaming.
|
|
*/
|
|
|
|
/** Cancel an in-flight chat generation. */
|
|
export function cancelChatResponse(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(
|
|
withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/cancel`, projectId),
|
|
{
|
|
method: "POST",
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Send a chat message and receive the AI response via SSE streaming.
|
|
*
|
|
* The backend exposes `POST /api/chat/sessions/:id/messages` which returns an SSE
|
|
* stream (not JSON). Events: `thinking`, `text`, `fallback`, `done`, `error`.
|
|
*
|
|
* Since `EventSource` only supports GET requests, this function uses `fetch()`
|
|
* with a ReadableStream to parse SSE events from the POST response body.
|
|
* When attachments are provided, the request body is sent as multipart form data;
|
|
* otherwise it uses the existing JSON payload path.
|
|
*/
|
|
export interface ChatStreamHandlers {
|
|
onThinking?: (data: string) => void;
|
|
onText?: (data: string) => void;
|
|
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
|
|
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
|
|
onFallback?: (data: { primaryModel: string; fallbackModel: string; triggerPoint: "session-creation" | "prompt-time" }) => void;
|
|
onDone?: (data: { messageId: string; message?: ChatMessage }) => void;
|
|
onError?: (data: string) => void;
|
|
onConnectionStateChange?: (state: StreamConnectionState) => void;
|
|
}
|
|
|
|
export function streamChatResponse(
|
|
sessionId: string,
|
|
content: string,
|
|
handlers: ChatStreamHandlers,
|
|
attachments?: File[],
|
|
projectId?: string,
|
|
options?: { maxReconnectAttempts?: number; firstEventTimeoutMs?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, projectId));
|
|
|
|
const abortController = new AbortController();
|
|
let closedByUser = false;
|
|
let terminated = false;
|
|
let receivedStreamEvent = false;
|
|
const firstEventTimeoutMs = Math.max(1_000, options?.firstEventTimeoutMs ?? 60_000);
|
|
let firstEventTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const clearFirstEventTimer = (): void => {
|
|
if (firstEventTimer) {
|
|
clearTimeout(firstEventTimer);
|
|
firstEventTimer = null;
|
|
}
|
|
};
|
|
|
|
const markFirstEventReceived = (): void => {
|
|
if (receivedStreamEvent) {
|
|
return;
|
|
}
|
|
receivedStreamEvent = true;
|
|
clearFirstEventTimer();
|
|
};
|
|
|
|
const dispatchEvent = (eventName: string, rawData: string): void => {
|
|
if (!eventName) {
|
|
return;
|
|
}
|
|
|
|
markFirstEventReceived();
|
|
|
|
switch (eventName) {
|
|
case "thinking":
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(rawData));
|
|
} catch {
|
|
handlers.onThinking?.(rawData);
|
|
}
|
|
break;
|
|
case "text":
|
|
try {
|
|
handlers.onText?.(JSON.parse(rawData));
|
|
} catch {
|
|
handlers.onText?.(rawData);
|
|
}
|
|
break;
|
|
case "tool_start":
|
|
try {
|
|
handlers.onToolStart?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "tool_end":
|
|
try {
|
|
handlers.onToolEnd?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "fallback":
|
|
try {
|
|
handlers.onFallback?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "done":
|
|
terminated = true;
|
|
try {
|
|
const parsed = JSON.parse(rawData) as { messageId?: unknown; message?: unknown };
|
|
handlers.onDone?.({
|
|
messageId: typeof parsed.messageId === "string" ? parsed.messageId : "",
|
|
...(parsed.message && typeof parsed.message === "object" ? { message: parsed.message as ChatMessage } : {}),
|
|
});
|
|
} catch {
|
|
handlers.onDone?.({ messageId: "" });
|
|
}
|
|
break;
|
|
case "error":
|
|
terminated = true;
|
|
try {
|
|
const parsed = JSON.parse(rawData);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(rawData || "Stream error");
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Start streaming via POST
|
|
(async () => {
|
|
try {
|
|
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
const body = hasAttachments
|
|
? (() => {
|
|
const formData = new FormData();
|
|
formData.append("content", content);
|
|
attachments.forEach((file) => formData.append("attachments", file));
|
|
return formData;
|
|
})()
|
|
: JSON.stringify({ content });
|
|
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: hasAttachments ? withTokenHeader() : withTokenHeader({ "Content-Type": "application/json" }),
|
|
body,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorBody = await res.text();
|
|
let errorMsg = `Request failed: ${res.status}`;
|
|
try {
|
|
const parsed = JSON.parse(errorBody);
|
|
errorMsg = parsed.error || errorMsg;
|
|
} catch { /* use default */ }
|
|
handlers.onError?.(errorMsg);
|
|
return;
|
|
}
|
|
|
|
if (!res.body) {
|
|
handlers.onError?.("No response body");
|
|
return;
|
|
}
|
|
|
|
handlers.onConnectionStateChange?.("connected");
|
|
firstEventTimer = setTimeout(() => {
|
|
if (terminated || closedByUser || receivedStreamEvent) {
|
|
return;
|
|
}
|
|
terminated = true;
|
|
handlers.onError?.("Timed out waiting for first response event");
|
|
abortController.abort();
|
|
}, firstEventTimeoutMs);
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let currentEvent = "";
|
|
let currentDataLines: string[] = [];
|
|
|
|
// POST-based chat responses still speak SSE, so parser state must persist
|
|
// across ReadableStream chunks. Networks can split `event:` and `data:`
|
|
// lines arbitrarily, and resetting state per-read drops assistant output.
|
|
const processLines = (chunk: string, flushPendingEvent = false): void => {
|
|
buffer += chunk;
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
// At stream end, flush any remaining buffered line so complete trailing
|
|
// events are parsed even when the payload has no final newline.
|
|
if (flushPendingEvent && buffer.length > 0) {
|
|
lines.push(buffer);
|
|
buffer = "";
|
|
}
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
|
|
if (line.startsWith("event:")) {
|
|
currentEvent = line.slice(6).trim();
|
|
} else if (line.startsWith("data:")) {
|
|
const value = line.slice(5);
|
|
currentDataLines.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
} else if (line === "") {
|
|
const currentData = currentDataLines.join("\n");
|
|
dispatchEvent(currentEvent, currentData);
|
|
currentEvent = "";
|
|
currentDataLines = [];
|
|
}
|
|
}
|
|
|
|
// Flush any pending event/data at stream end.
|
|
// Only dispatch if we have both a valid event type and accumulated data.
|
|
if (flushPendingEvent && currentEvent && currentDataLines.length > 0) {
|
|
const trailingData = currentDataLines.join("\n");
|
|
dispatchEvent(currentEvent, trailingData);
|
|
currentEvent = "";
|
|
currentDataLines = [];
|
|
}
|
|
};
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
processLines(decoder.decode(), true);
|
|
break;
|
|
}
|
|
|
|
processLines(decoder.decode(value, { stream: true }));
|
|
}
|
|
|
|
const hasUndispatchedTrailingFragment =
|
|
buffer.length > 0 || currentEvent.length > 0 || currentDataLines.length > 0;
|
|
|
|
// Server closed the stream without emitting a terminal `done` or `error`
|
|
// SSE event (common on flaky mobile networks, proxy idle-kill, or
|
|
// backgrounded tabs). Surface as an error so the client unwinds
|
|
// streaming state instead of getting stuck with isStreaming=true.
|
|
// Ignore dangling partial fragments at EOF: those indicate a truncated
|
|
// trailing event that should be dropped rather than surfaced as transport
|
|
// failure.
|
|
if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) {
|
|
handlers.onError?.("Connection closed unexpectedly");
|
|
}
|
|
clearFirstEventTimer();
|
|
} catch (err: unknown) {
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
if (!closedByUser && !terminated) {
|
|
handlers.onError?.("Connection aborted");
|
|
}
|
|
clearFirstEventTimer();
|
|
return;
|
|
}
|
|
if (closedByUser) {
|
|
clearFirstEventTimer();
|
|
return;
|
|
}
|
|
clearFirstEventTimer();
|
|
handlers.onError?.(err instanceof Error ? err.message : "Connection error");
|
|
}
|
|
})();
|
|
|
|
return {
|
|
close: () => {
|
|
closedByUser = true;
|
|
clearFirstEventTimer();
|
|
abortController.abort();
|
|
},
|
|
isConnected: () => !closedByUser,
|
|
};
|
|
}
|
|
|
|
export function attachChatStream(
|
|
sessionId: string,
|
|
handlers: ChatStreamHandlers,
|
|
projectId?: string,
|
|
options?: { lastEventId?: number },
|
|
): { close: () => void; isConnected: () => boolean } {
|
|
const url = buildApiUrl(withProjectId(`/chat/sessions/${encodeURIComponent(sessionId)}/stream`, projectId));
|
|
const abortController = new AbortController();
|
|
let closedByUser = false;
|
|
let terminated = false;
|
|
|
|
const dispatchEvent = (eventName: string, rawData: string): void => {
|
|
if (!eventName) {
|
|
return;
|
|
}
|
|
|
|
switch (eventName) {
|
|
case "thinking":
|
|
try {
|
|
handlers.onThinking?.(JSON.parse(rawData));
|
|
} catch {
|
|
handlers.onThinking?.(rawData);
|
|
}
|
|
break;
|
|
case "text":
|
|
try {
|
|
handlers.onText?.(JSON.parse(rawData));
|
|
} catch {
|
|
handlers.onText?.(rawData);
|
|
}
|
|
break;
|
|
case "tool_start":
|
|
try {
|
|
handlers.onToolStart?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "tool_end":
|
|
try {
|
|
handlers.onToolEnd?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "fallback":
|
|
try {
|
|
handlers.onFallback?.(JSON.parse(rawData));
|
|
} catch {
|
|
// skip malformed event
|
|
}
|
|
break;
|
|
case "done":
|
|
terminated = true;
|
|
try {
|
|
const parsed = JSON.parse(rawData) as { messageId?: unknown; message?: unknown };
|
|
handlers.onDone?.({
|
|
messageId: typeof parsed.messageId === "string" ? parsed.messageId : "",
|
|
...(parsed.message && typeof parsed.message === "object" ? { message: parsed.message as ChatMessage } : {}),
|
|
});
|
|
} catch {
|
|
handlers.onDone?.({ messageId: "" });
|
|
}
|
|
break;
|
|
case "error":
|
|
terminated = true;
|
|
try {
|
|
const parsed = JSON.parse(rawData);
|
|
handlers.onError?.(parsed.message || parsed);
|
|
} catch {
|
|
handlers.onError?.(rawData || "Stream error");
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
try {
|
|
const requestHeaders = new Headers(withTokenHeader() as HeadersInit);
|
|
if (typeof options?.lastEventId === "number") {
|
|
requestHeaders.set("Last-Event-ID", String(options.lastEventId));
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
method: "GET",
|
|
headers: requestHeaders,
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorBody = await res.text();
|
|
let errorMsg = `Request failed: ${res.status}`;
|
|
try {
|
|
const parsed = JSON.parse(errorBody);
|
|
errorMsg = parsed.error || errorMsg;
|
|
} catch { /* use default */ }
|
|
handlers.onError?.(errorMsg);
|
|
return;
|
|
}
|
|
|
|
if (!res.body) {
|
|
handlers.onError?.("No response body");
|
|
return;
|
|
}
|
|
|
|
handlers.onConnectionStateChange?.("connected");
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let currentEvent = "";
|
|
let currentDataLines: string[] = [];
|
|
|
|
const processLines = (chunk: string, flushPendingEvent = false): void => {
|
|
buffer += chunk;
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
if (flushPendingEvent && buffer.length > 0) {
|
|
lines.push(buffer);
|
|
buffer = "";
|
|
}
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
|
|
if (line.startsWith("event:")) {
|
|
currentEvent = line.slice(6).trim();
|
|
} else if (line.startsWith("data:")) {
|
|
const value = line.slice(5);
|
|
currentDataLines.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
} else if (line === "") {
|
|
const currentData = currentDataLines.join("\n");
|
|
dispatchEvent(currentEvent, currentData);
|
|
currentEvent = "";
|
|
currentDataLines = [];
|
|
}
|
|
}
|
|
|
|
if (flushPendingEvent && currentEvent && currentDataLines.length > 0) {
|
|
const trailingData = currentDataLines.join("\n");
|
|
dispatchEvent(currentEvent, trailingData);
|
|
currentEvent = "";
|
|
currentDataLines = [];
|
|
}
|
|
};
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
processLines(decoder.decode(), true);
|
|
break;
|
|
}
|
|
|
|
processLines(decoder.decode(value, { stream: true }));
|
|
}
|
|
|
|
const hasUndispatchedTrailingFragment =
|
|
buffer.length > 0 || currentEvent.length > 0 || currentDataLines.length > 0;
|
|
|
|
if (!terminated && !closedByUser && !hasUndispatchedTrailingFragment) {
|
|
return;
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
if (!closedByUser && !terminated) {
|
|
handlers.onError?.("Connection aborted");
|
|
}
|
|
return;
|
|
}
|
|
if (closedByUser) {
|
|
return;
|
|
}
|
|
handlers.onError?.(err instanceof Error ? err.message : "Connection error");
|
|
}
|
|
})();
|
|
|
|
return {
|
|
close: () => {
|
|
closedByUser = true;
|
|
abortController.abort();
|
|
},
|
|
isConnected: () => !closedByUser,
|
|
};
|
|
}
|
|
|
|
|
|
// ── Insights API ─────────────────────────────────────────────────────────────
|
|
|
|
export interface InsightsListResponse {
|
|
insights: Insight[];
|
|
count: number;
|
|
}
|
|
|
|
export interface RunsListResponse {
|
|
runs: InsightRun[];
|
|
}
|
|
|
|
/**
|
|
* List insights for a project with optional filtering.
|
|
*/
|
|
export function fetchInsights(
|
|
options: {
|
|
category?: InsightCategory;
|
|
status?: InsightStatus;
|
|
runId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
} = {},
|
|
projectId?: string,
|
|
): Promise<InsightsListResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options.category) params.set("category", options.category);
|
|
if (options.status) params.set("status", options.status);
|
|
if (options.runId) params.set("runId", options.runId);
|
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options.offset !== undefined) params.set("offset", String(options.offset));
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<InsightsListResponse>(withProjectId(`/insights${suffix}`, projectId));
|
|
}
|
|
|
|
/**
|
|
* Get a single insight by ID.
|
|
*/
|
|
export function fetchInsight(id: string, projectId?: string): Promise<Insight> {
|
|
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
/**
|
|
* Update an insight.
|
|
*/
|
|
export function updateInsight(
|
|
id: string,
|
|
updates: {
|
|
title?: string;
|
|
content?: string | null;
|
|
category?: InsightCategory;
|
|
status?: InsightStatus;
|
|
},
|
|
projectId?: string,
|
|
): Promise<Insight> {
|
|
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete an insight.
|
|
*/
|
|
export function deleteInsight(id: string, projectId?: string): Promise<void> {
|
|
return api<void>(withProjectId(`/insights/${encodeURIComponent(id)}`, projectId), {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dismiss an insight (set status to dismissed).
|
|
*/
|
|
export function dismissInsight(id: string, projectId?: string): Promise<Insight> {
|
|
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}/dismiss`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Archive an insight (set status to archived).
|
|
*/
|
|
export function archiveInsight(id: string, projectId?: string): Promise<Insight> {
|
|
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}/archive`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unarchive an insight (set status back to confirmed).
|
|
*/
|
|
export function unarchiveInsight(id: string, projectId?: string): Promise<Insight> {
|
|
return api<Insight>(withProjectId(`/insights/${encodeURIComponent(id)}/unarchive`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger a manual insight generation run.
|
|
*/
|
|
export function triggerInsightRun(
|
|
trigger: InsightRunTrigger = "manual",
|
|
inputMetadata?: InsightRun["inputMetadata"],
|
|
projectId?: string,
|
|
modelProvider?: string,
|
|
modelId?: string,
|
|
): Promise<InsightRun> {
|
|
const body: Record<string, unknown> = { trigger, inputMetadata };
|
|
if (modelProvider) body.modelProvider = modelProvider;
|
|
if (modelId) body.modelId = modelId;
|
|
return api<InsightRun>(withProjectId("/insights/run", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List insight generation runs.
|
|
*/
|
|
export function fetchInsightRuns(projectId?: string): Promise<RunsListResponse> {
|
|
return api<RunsListResponse>(withProjectId("/insights/runs", projectId));
|
|
}
|
|
|
|
/**
|
|
* Get a single insight run by ID.
|
|
*/
|
|
export function fetchInsightRun(id: string, projectId?: string): Promise<InsightRun> {
|
|
return api<InsightRun>(withProjectId(`/insights/runs/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
/**
|
|
* Get data needed to create a task from an insight.
|
|
*/
|
|
export function getInsightCreateTaskData(
|
|
id: string,
|
|
projectId?: string,
|
|
): Promise<{
|
|
success: boolean;
|
|
insight: Insight;
|
|
suggestedTitle: string;
|
|
suggestedDescription: string;
|
|
}> {
|
|
return api(withProjectId(`/insights/${encodeURIComponent(id)}/create-task`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// ── Research API ────────────────────────────────────────────────────────────
|
|
|
|
export interface EvalsListOptions {
|
|
q?: string;
|
|
runId?: string;
|
|
scoreMin?: number;
|
|
scoreMax?: number;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export function listEvals(options: EvalsListOptions = {}, projectId?: string): Promise<{ results: EvalTaskResult[]; count: number }> {
|
|
const params = new URLSearchParams();
|
|
if (options.q) params.set("q", options.q);
|
|
if (options.runId) params.set("runId", options.runId);
|
|
if (options.scoreMin !== undefined) params.set("scoreMin", String(options.scoreMin));
|
|
if (options.scoreMax !== undefined) params.set("scoreMax", String(options.scoreMax));
|
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
if (options.offset !== undefined) params.set("offset", String(options.offset));
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<{ results: EvalTaskResult[]; count: number }>(withProjectId(`/evals${suffix}`, projectId));
|
|
}
|
|
|
|
export function getEval(id: string, projectId?: string): Promise<{ result: EvalTaskResult }> {
|
|
return api<{ result: EvalTaskResult }>(withProjectId(`/evals/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
export function listEvalRuns(projectId?: string): Promise<{ runs: EvalRun[] }> {
|
|
return api<{ runs: EvalRun[] }>(withProjectId("/evals/runs", projectId));
|
|
}
|
|
|
|
export interface CreateResearchRunInput {
|
|
query: string;
|
|
providers: ResearchProviderOption[];
|
|
githubRepo?: string;
|
|
githubIssueNumber?: number;
|
|
includeLocalDocs?: boolean;
|
|
enableSynthesis?: boolean;
|
|
maxResults?: number;
|
|
depth?: "shallow" | "normal" | "deep";
|
|
}
|
|
|
|
export function listResearchRuns(
|
|
options: { q?: string; status?: ResearchRunStatus; limit?: number } = {},
|
|
projectId?: string,
|
|
): Promise<ResearchRunsResponse> {
|
|
const params = new URLSearchParams();
|
|
if (options.q) params.set("q", options.q);
|
|
if (options.status) params.set("status", options.status);
|
|
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<ResearchRunsResponse>(withProjectId(`/research/runs${suffix}`, projectId));
|
|
}
|
|
|
|
export function createResearchRun(input: CreateResearchRunInput, projectId?: string): Promise<ResearchRunResponse> {
|
|
return api<ResearchRunResponse>(withProjectId("/research/runs", projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
}
|
|
|
|
export function getResearchRun(id: string, projectId?: string): Promise<ResearchRunResponse> {
|
|
return api<ResearchRunResponse>(withProjectId(`/research/runs/${encodeURIComponent(id)}`, projectId));
|
|
}
|
|
|
|
export type ResearchActionErrorCode =
|
|
| "FEATURE_DISABLED"
|
|
| "MISSING_CREDENTIALS"
|
|
| "PROVIDER_UNAVAILABLE"
|
|
| "RATE_LIMITED"
|
|
| "PROVIDER_TIMEOUT"
|
|
| "RUN_CANCELLED"
|
|
| "RETRY_EXHAUSTED"
|
|
| "INVALID_TRANSITION"
|
|
| "NON_RETRYABLE_PROVIDER_ERROR"
|
|
| "INTERNAL_ERROR";
|
|
|
|
export interface ResearchActionError extends ApiRequestError {
|
|
researchCode: ResearchActionErrorCode;
|
|
setupHint?: string;
|
|
retryable?: boolean;
|
|
}
|
|
|
|
function asResearchActionError(error: unknown): never {
|
|
if (error instanceof ApiRequestError) {
|
|
const codeCandidate = error.details?.code;
|
|
const code = typeof codeCandidate === "string" ? codeCandidate : "INTERNAL_ERROR";
|
|
const setupHint = typeof error.details?.setupHint === "string" ? error.details.setupHint : undefined;
|
|
const retryable = typeof error.details?.retryable === "boolean" ? error.details.retryable : undefined;
|
|
const enriched = error as ResearchActionError;
|
|
enriched.researchCode = code as ResearchActionErrorCode;
|
|
enriched.setupHint = setupHint;
|
|
enriched.retryable = retryable;
|
|
throw enriched;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
export async function cancelResearchRun(id: string, projectId?: string): Promise<{ run: ResearchRunDetail }> {
|
|
try {
|
|
return await api<{ run: ResearchRunDetail }>(withProjectId(`/research/runs/${encodeURIComponent(id)}/cancel`, projectId), {
|
|
method: "POST",
|
|
});
|
|
} catch (error) {
|
|
asResearchActionError(error);
|
|
}
|
|
}
|
|
|
|
export async function retryResearchRun(id: string, projectId?: string): Promise<{ run: ResearchRunDetail }> {
|
|
try {
|
|
return await api<{ run: ResearchRunDetail }>(withProjectId(`/research/runs/${encodeURIComponent(id)}/retry`, projectId), {
|
|
method: "POST",
|
|
});
|
|
} catch (error) {
|
|
asResearchActionError(error);
|
|
}
|
|
}
|
|
|
|
export function exportResearchRun(
|
|
id: string,
|
|
format: "markdown" | "json" | "html",
|
|
projectId?: string,
|
|
): Promise<{ format: string; content: string; filename: string }> {
|
|
return api<{ format: string; content: string; filename: string }>(
|
|
withProjectId(`/research/runs/${encodeURIComponent(id)}/export?format=${encodeURIComponent(format)}`, projectId),
|
|
);
|
|
}
|
|
|
|
export function createTaskFromResearchRun(
|
|
id: string,
|
|
input: { findingId?: string; title?: string; description?: string; priority?: "low" | "normal" | "high" | "urgent"; attachExport?: boolean },
|
|
projectId?: string,
|
|
): Promise<{ task: Task; documentKey: string; attachmentFilename?: string }> {
|
|
const findingId = input.findingId ?? "finding-1";
|
|
return api<{ task: Task; documentKey: string; attachmentFilename?: string }>(
|
|
withProjectId(`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/task`, projectId),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: input.title,
|
|
description: input.description,
|
|
priority: input.priority,
|
|
attachExport: input.attachExport,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function attachResearchRunToTask(
|
|
id: string,
|
|
input: { findingId?: string; taskId: string; attachExport?: boolean },
|
|
projectId?: string,
|
|
): Promise<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }> {
|
|
const findingId = input.findingId ?? "finding-1";
|
|
return api<{ taskId: string; documentKey: string; revision: number; attachmentFilename?: string }>(
|
|
withProjectId(
|
|
`/research/runs/${encodeURIComponent(id)}/findings/${encodeURIComponent(findingId)}/tasks/${encodeURIComponent(input.taskId)}/enrich`,
|
|
projectId,
|
|
),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
attachExport: input.attachExport,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
|
|
export function getResearchAvailability(projectId?: string): Promise<ResearchAvailability> {
|
|
return listResearchRuns({}, projectId).then((response) => response.availability);
|
|
}
|
|
|
|
export interface ResearchStatsResponse {
|
|
total: number;
|
|
byStatus: Record<ResearchRunStatus, number>;
|
|
}
|
|
|
|
export function getResearchStats(projectId?: string): Promise<ResearchStatsResponse> {
|
|
return api<ResearchStatsResponse>(withProjectId("/research/stats", projectId));
|
|
}
|