9324 lines
306 KiB
TypeScript
9324 lines
306 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,
|
|
MeshClusterSnapshot,
|
|
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,
|
|
TaskIdIntegrityReport,
|
|
} from "@fusion/core";
|
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
|
import type { GithubIssueAction, 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, MissionInterviewDraftSummary } 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;
|
|
githubIssueAction?: GithubIssueAction;
|
|
}
|
|
|
|
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;
|
|
database: {
|
|
healthy: boolean;
|
|
lastCheckedAt: string | null;
|
|
isRunning: boolean;
|
|
};
|
|
taskIdIntegrity: TaskIdIntegrityReport & {
|
|
recommendedAction: string | null;
|
|
};
|
|
}
|
|
|
|
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
|
return api<DashboardHealthResponse>("/health");
|
|
}
|
|
|
|
export function refreshDashboardHealth(): Promise<DashboardHealthResponse> {
|
|
return api<DashboardHealthResponse>("/health/refresh", { method: "POST" });
|
|
}
|
|
|
|
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,
|
|
githubTracking,
|
|
} = 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,
|
|
githubTracking,
|
|
}),
|
|
});
|
|
}
|
|
|
|
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;
|
|
noCommitsExpected?: boolean;
|
|
priority?: TaskPriority | null;
|
|
sourceIssue?: TaskSourceIssue | null;
|
|
nodeId?: string | null;
|
|
branch?: string | null;
|
|
baseBranch?: string | null;
|
|
githubTracking?: {
|
|
enabled?: boolean;
|
|
repoOverride?: string | null;
|
|
issue?: null;
|
|
} | 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");
|
|
}
|
|
if (options?.githubIssueAction) {
|
|
search.set("githubIssueAction", options.githubIssueAction);
|
|
}
|
|
|
|
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;
|
|
ntfyAccessToken?: 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;
|
|
/** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */
|
|
requiresManualCode?: 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[];
|
|
}
|
|
|
|
export interface PlanningSubtaskDraft {
|
|
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: PlanningSubtaskDraft[],
|
|
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 };
|
|
|
|
export interface AgentPromptSizePoint {
|
|
runId: string;
|
|
createdAt: string;
|
|
systemChars: number;
|
|
execChars: number;
|
|
totalChars: number;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
/** Fetch recent prompt size points for an agent */
|
|
export function fetchAgentPromptSizes(agentId: string, limit?: number, projectId?: string): Promise<AgentPromptSizePoint[]> {
|
|
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<AgentPromptSizePoint[]>(`/agents/${encodeURIComponent(agentId)}/prompt-sizes${query}`);
|
|
}
|
|
|
|
/** 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<MeshClusterSnapshot> {
|
|
return api<MeshClusterSnapshot>("/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}`);
|
|
}
|
|
|
|
export interface TaskCommitAssociationRow {
|
|
commitSha: string;
|
|
commitSubject: string;
|
|
authoredAt: string;
|
|
matchedBy: "canonical-lineage-trailer" | "legacy-task-id-trailer" | "legacy-subject" | "manual-reconciliation";
|
|
confidence: "canonical" | "legacy" | "ambiguous";
|
|
taskIdSnapshot: string;
|
|
note?: string;
|
|
}
|
|
|
|
export interface TaskCommitAssociationsResponse {
|
|
taskId: string;
|
|
lineageId: string | null;
|
|
associations: TaskCommitAssociationRow[];
|
|
}
|
|
|
|
/** Fetch lineage commit associations for a task */
|
|
export function fetchTaskCommitAssociations(taskId: string, projectId?: string): Promise<TaskCommitAssociationsResponse> {
|
|
return api<TaskCommitAssociationsResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/commit-associations`, projectId));
|
|
}
|
|
|
|
/** 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 }),
|
|
});
|
|
}
|
|
|
|
export async function fetchMissionInterviewDrafts(projectId?: string): Promise<MissionInterviewDraftSummary[]> {
|
|
const query = projectId ? `?${new URLSearchParams({ projectId }).toString()}` : "";
|
|
const result = await api<{ drafts?: MissionInterviewDraftSummary[] }>(`/missions/interview/drafts${query}`);
|
|
return result.drafts ?? [];
|
|
}
|
|
|
|
export function discardMissionInterviewDraft(
|
|
sessionId: string,
|
|
projectId?: string,
|
|
tabId?: string,
|
|
): Promise<{ removed: boolean }> {
|
|
return api<{ removed: boolean }>(
|
|
withProjectId(`/missions/interview/drafts/${encodeURIComponent(sessionId)}/discard`, projectId),
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ 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[];
|
|
}
|
|
|
|
/** Response shape for GET /agents/mailbox/all */
|
|
export interface AllAgentsMailboxResponse {
|
|
messages: Message[];
|
|
total: number;
|
|
unreadCount: number;
|
|
}
|
|
|
|
/** 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));
|
|
}
|
|
|
|
/** Fetch aggregate mailbox across all agent-to-agent messages (admin read-only view). */
|
|
export function fetchAllAgentMailbox(projectId?: string): Promise<AllAgentsMailboxResponse> {
|
|
return api<AllAgentsMailboxResponse>(withProjectId("/agents/mailbox/all", 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 ChatFailureReference {
|
|
kind: string;
|
|
id: string;
|
|
label?: string;
|
|
}
|
|
|
|
export interface ChatFailureInfo {
|
|
summary: string;
|
|
errorClass?: string;
|
|
code?: string;
|
|
detail?: string;
|
|
reference?: ChatFailureReference;
|
|
}
|
|
|
|
function extractChatFailureInfo(value: unknown): ChatFailureInfo | null {
|
|
if (!value || typeof value !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const record = value as Record<string, unknown>;
|
|
const summary = typeof record.summary === "string" ? record.summary.trim() : "";
|
|
if (!summary) {
|
|
return null;
|
|
}
|
|
|
|
const reference = (() => {
|
|
const rawReference = record.reference;
|
|
if (!rawReference || typeof rawReference !== "object") {
|
|
return undefined;
|
|
}
|
|
const referenceRecord = rawReference as Record<string, unknown>;
|
|
const kind = typeof referenceRecord.kind === "string" ? referenceRecord.kind.trim() : "";
|
|
const id = typeof referenceRecord.id === "string" ? referenceRecord.id.trim() : "";
|
|
if (!kind || !id) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
kind,
|
|
id,
|
|
...(typeof referenceRecord.label === "string" && referenceRecord.label.trim()
|
|
? { label: referenceRecord.label.trim() }
|
|
: {}),
|
|
} satisfies ChatFailureReference;
|
|
})();
|
|
|
|
return {
|
|
summary,
|
|
...(typeof record.errorClass === "string" && record.errorClass.trim()
|
|
? { errorClass: record.errorClass.trim() }
|
|
: {}),
|
|
...(typeof record.code === "string" && record.code.trim()
|
|
? { code: record.code.trim() }
|
|
: {}),
|
|
...(typeof record.detail === "string" && record.detail.trim()
|
|
? { detail: record.detail.trim() }
|
|
: {}),
|
|
...(reference ? { reference } : {}),
|
|
};
|
|
}
|
|
|
|
function parseChatErrorPayload(rawData: string): string | ChatFailureInfo {
|
|
try {
|
|
const parsed = JSON.parse(rawData);
|
|
const structured = extractChatFailureInfo(parsed);
|
|
if (structured) {
|
|
return structured;
|
|
}
|
|
if (parsed && typeof parsed === "object" && typeof (parsed as { message?: unknown }).message === "string") {
|
|
return (parsed as { message: string }).message;
|
|
}
|
|
return typeof parsed === "string" ? parsed : rawData || "Stream error";
|
|
} catch {
|
|
return rawData || "Stream error";
|
|
}
|
|
}
|
|
|
|
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 | ChatFailureInfo) => 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;
|
|
handlers.onError?.(parseChatErrorPayload(rawData));
|
|
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);
|
|
// Strip only the optional SSE protocol delimiter-space after `data:`.
|
|
// Payload whitespace (including JSON-string leading spaces) must stay verbatim.
|
|
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;
|
|
handlers.onError?.(parseChatErrorPayload(rawData));
|
|
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);
|
|
// Strip only the optional SSE protocol delimiter-space after `data:`.
|
|
// Payload whitespace (including JSON-string leading spaces) must stay verbatim.
|
|
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));
|
|
}
|