maestro/ui/src/api.ts
oss-sync 3b1645cc91
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d31b280)
2026-06-11 11:28:40 +00:00

1329 lines
47 KiB
TypeScript

const BASE = '/api';
/**
* 4-state representation of a secret field on the wire. See design doc
* 2026-05-21-settings-ui-and-config-restructure-design.md (Form Behavior →
* Secret Inputs) for the canonical contract.
*
* Phase 1 (this release) keeps the on-wire representation backwards
* compatible with the existing `apiKey: string` shape so that mask-
* preservation in `ConfigManager.updateConfig` keeps working unchanged:
*
* - `unchanged` → serialized as the masked sentinel `'********'`
* - `literal` → serialized as the raw string value
* - `env_ref` → serialized as `${ENV_NAME}`
* - `cleared` → serialized as `''` (empty string)
*
* Phase 2 will switch to a tagged object on the wire and drop the magic
* `'********'` sentinel; the UI form keeps the 4-state union today so the
* Phase 2 migration is a server-side change only.
*/
export type SecretFieldValue =
| { type: 'unchanged' }
| { type: 'literal'; value: string }
| { type: 'env_ref'; env_name: string }
| { type: 'cleared' };
/** Server-side masked sentinel. Kept in sync with `MASKED` in src/config-manager.ts. */
export const SECRET_MASKED_SENTINEL = '********';
/**
* Parse a stored string secret (as received from `GET /api/config`) into
* the 4-state form used by the UI. The server masks literal secrets to
* `'********'`, so any literal-looking string is treated as `unchanged`
* unless it's an `${ENV_REF}` pattern. Empty / missing values map to
* `cleared`.
*/
export function parseSecretValue(raw: string | null | undefined): SecretFieldValue {
if (raw == null || raw === '') return { type: 'cleared' };
if (raw === SECRET_MASKED_SENTINEL) return { type: 'unchanged' };
const envMatch = /^\$\{([A-Z0-9_]+)\}$/.exec(raw.trim());
if (envMatch) return { type: 'env_ref', env_name: envMatch[1] };
// Anything else came back as plaintext (e.g. fresh UI form not yet
// round-tripped through the server mask) — treat as literal.
return { type: 'literal', value: raw };
}
/**
* Serialize a 4-state secret into the string the server currently
* expects. `unchanged` becomes the masked sentinel so server-side
* mask-preservation in `ConfigManager.updateConfig` keeps the existing
* literal in place.
*/
export function serializeSecretValue(v: SecretFieldValue): string {
if (v.type === 'unchanged') return SECRET_MASKED_SENTINEL;
if (v.type === 'literal') return v.value;
if (v.type === 'env_ref') return `\${${v.env_name}}`;
return '';
}
export type PieceName = string; // Dynamically loaded from API
export type ProfileName = 'auto' | 'fast' | 'quality';
export type OutputFormat = 'text' | 'markdown' | 'json';
export type AskPolicy = 'low' | 'high';
export type Priority = 'low' | 'medium' | 'high';
export type Visibility = 'private' | 'org' | 'public';
export interface UserOrg {
orgId: string;
orgName: string;
fetchedAt: string;
}
export async function fetchMyOrgs(): Promise<UserOrg[]> {
const res = await fetch('/api/users/me/orgs');
if (!res.ok) return [];
const { orgs } = (await res.json()) as { orgs: Array<{ orgId: string; orgName: string; fetchedAt: string }> };
return orgs;
}
export interface SubtaskInfo {
id: string;
issueNumber: number;
status: string;
instruction: string;
worktreePath: string | null;
createdAt: string;
updatedAt: string;
children?: SubtaskInfo[];
childCount?: number;
childCompleted?: number;
}
export interface SubtaskActivity {
jobId: string;
issueNumber: number;
status: string;
currentMovement: string | null;
currentActivity: string | null;
activityLog: string;
}
export type TitleSource = 'auto' | 'agent' | 'user';
export interface LocalTask {
id: number;
title: string;
/** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
titleSource?: TitleSource;
body: string;
pieceName: string;
profile: string;
outputFormat: string;
askPolicy: string;
priority: string;
state: string;
workspacePath: string | null;
ownerId?: string | null;
ownerName?: string | null;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
visibilityScopeOrgName?: string | null;
createdAt: string;
updatedAt: string;
latestJob?: {
id: string;
status: string;
waitReason?: string | null;
currentMovement?: string | null;
currentActivity?: string | null;
workerId?: string | null;
/**
* Physical backend id (e.g. LiteLLM deployment) for jobs run through
* a proxy worker. NULL until the proxy has resolved a backend or for
* direct workers entirely.
* Phase A: docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md.
*/
lastBackendId?: string | null;
contextPromptTokens?: number | null;
contextLimitTokens?: number | null;
contextUpdatedAt?: string | null;
} | null;
subtasks?: SubtaskInfo[];
subtaskCount?: number;
subtaskCompleted?: number;
feedbackRating?: 'good' | 'bad' | null;
feedbackTags?: string[] | null;
feedbackComment?: string | null;
feedbackAt?: string | null;
shareToken?: string | null;
sharedAt?: string | null;
missionBrief?: MissionBrief | null;
}
export interface MissionBrief {
goal: string;
done: string;
open: string;
clarifications: string;
}
export async function updateMissionBrief(
taskId: number,
patch: Partial<MissionBrief>,
): Promise<MissionBrief | null> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/mission`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
return data.missionBrief ?? null;
}
export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';
export interface LocalTaskComment {
id: number;
taskId: number;
author: string;
kind: CommentKind;
body: string;
createdAt: string;
injectedAt: string | null;
}
export interface LocalFileEntry {
name: string;
path: string;
kind: 'directory' | 'file';
size: number;
modifiedAt: string;
}
export interface CreateLocalTaskInput {
title?: string;
body: string;
piece: PieceName;
profile: ProfileName;
outputFormat: OutputFormat;
askPolicy: AskPolicy;
priority: Priority;
attachments?: Array<{ name: string; contentBase64: string }>;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
browserSessionProfileId?: number | null;
options?: {
mcpDisabled?: boolean;
skillsDisabled?: boolean;
};
}
export async function fetchLocalTasks(): Promise<LocalTask[]> {
const res = await fetch(`${BASE}/local/tasks`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local tasks');
return data.tasks ?? [];
}
export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ task: LocalTask; jobId: string }> {
const res = await fetch(`${BASE}/local/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task');
return data;
}
export async function fetchLocalTask(taskId: number): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task');
return data.task;
}
export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskComment[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch local task comments');
return data.comments ?? [];
}
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
const payload: Record<string, unknown> = { body, author };
if (attachments && attachments.length > 0) payload.attachments = attachments;
const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to post local task comment');
}
export async function updateLocalTask(
taskId: number,
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update local task');
return data.task;
}
/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
export async function regenerateTaskTitle(taskId: number): Promise<string> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
return data.title as string;
}
export async function continueTaskWithPiece(
taskId: number,
body: { piece: string; instruction: string },
): Promise<{ jobId: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/continue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to continue task');
return data;
}
export async function deleteLocalTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task');
}
export async function cancelLocalTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, {
method: 'POST',
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task');
}
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise<string> {
const params = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return await res.text();
}
export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path, trusted: '1' });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
// --- Config ---
export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record<string, boolean> }> {
const res = await fetch(`${BASE}/config`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config');
return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} };
}
export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> {
const res = await fetch(`${BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': etag },
body: JSON.stringify(config),
});
const data = await res.json();
if (res.status === 409) return { ok: false, conflict: true };
if (!res.ok) throw new Error(data?.error ?? 'Failed to update config');
return data;
}
export async function reloadConfig(): Promise<void> {
const res = await fetch(`${BASE}/config/reload`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to reload config');
}
// --- Pieces ---
export interface DriftStatus { drifted: boolean; forkedFromCommit: string | null; latestCommit: string | null }
export interface PieceSummary { name: string; description: string; triggers?: { keywords: string[] }; custom?: boolean; source?: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string; drift?: DriftStatus; requiredMcp?: string[] }
export interface PieceDef { name: string; description: string; max_movements: number; initial_movement: string; triggers?: { keywords: string[] }; movements: any[]; requiredMcp?: string[] }
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
export async function fetchPieces(): Promise<PieceSummary[]> {
const res = await fetch(`${BASE}/pieces`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces');
return data.pieces;
}
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
}
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
}
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
const res = await fetch(`${BASE}/pieces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
const d = await res.json();
return { source: d.source ?? 'user-custom' };
}
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
}
// --- Tools ---
/**
* Runtime tool catalog entry. Mirrors `ToolCatalogEntry` exported by
* `src/bridge/tools-api.ts` (server side). See design doc step 4:
* docs/superpowers/specs/2026-05-21-settings-ui-and-config-restructure-design.md
*/
export interface ToolCatalogEntry {
name: string;
source: 'builtin' | 'meta' | 'mcp';
/**
* Coarse grouping for UI. For builtin/meta tools this is a module name
* (e.g. 'core', 'web'). For MCP tools the server uses `mcp:<serverId>`.
*/
category: string;
/** MCP server id (only set when source === 'mcp'). */
serverId?: string;
/** Whether the tool can be invoked right now. */
available: boolean;
/** Human-readable explanation when `available` is false. */
reason?: string;
/**
* - 'global' → meta tools auto-injected by the agent loop
* - 'piece' → must be listed in a piece's `allowed_tools`
* - 'user' → per-user resource (MCP / SSH)
*/
scope: 'global' | 'piece' | 'user';
}
export async function fetchTools(): Promise<ToolCatalogEntry[]> {
const res = await fetch(`${BASE}/tools`);
if (!res.ok) throw new Error('Failed to fetch tools');
const data = (await res.json()) as { tools?: unknown };
if (!Array.isArray(data.tools)) return [];
// Server may still occasionally serve the legacy flat-string shape (e.g.
// during a transient mismatch / proxy / cache). Filter to only well-formed
// catalog entries so the UI never crashes; legacy strings are dropped, the
// piece editor will then surface them as "unknown" entries (visible+disabled
// with a warning) once they appear in an existing piece's allowed_tools.
return data.tools.filter(
(t): t is ToolCatalogEntry =>
typeof t === 'object' && t !== null && typeof (t as { name?: unknown }).name === 'string',
);
}
// --- Subtasks ---
export interface SubtaskFiles {
files: string[];
categories: Record<string, string[]>;
}
export async function fetchSubtaskFiles(taskId: number, jobId: string): Promise<SubtaskFiles> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files');
return { files: data.files ?? [], categories: data.categories ?? {} };
}
export function subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string {
return `${BASE}/local/tasks/${taskId}/subtasks/${jobId}/files/${filePath}`;
}
export async function fetchSubtaskFileContent(taskId: number, jobId: string, filePath: string): Promise<string> {
const res = await fetch(subtaskFileRawUrl(taskId, jobId, filePath));
if (!res.ok) throw new Error('Failed to fetch subtask file content');
return res.text();
}
export async function fetchSubtaskActivities(taskId: number): Promise<SubtaskActivity[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/activities`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
return data.subtasks ?? [];
}
export async function fetchSubtaskActivity(taskId: number, jobId: string): Promise<string> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/subtasks/${jobId}/activity`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity');
return data.activityLog ?? '';
}
export async function putFeedback(
taskId: number,
feedback: { rating: 'good' | 'bad'; tags: string[]; comment?: string },
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/feedback`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedback),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update feedback');
return data.task;
}
// --- Share ---
export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to share task');
return data;
}
export async function unshareTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task');
}
export async function fetchSharedTask(token: string): Promise<LocalTask> {
const res = await fetch(`${BASE}/shared/${token}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Not found');
return data.task;
}
export async function fetchSharedTaskComments(token: string): Promise<LocalTaskComment[]> {
const res = await fetch(`${BASE}/shared/${token}/comments`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments');
return data.comments ?? [];
}
export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchSharedFileContent(token: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`);
if (!res.ok) throw new Error('Failed to read file');
return res.text();
}
export function getSharedFileRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/shared/${token}/files/raw?${params.toString()}`;
}
export async function fetchSharedSubtaskActivities(token: string): Promise<SubtaskActivity[]> {
const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
return data.subtasks ?? [];
}
// --- Browser Session Profiles ---
export interface BrowserSessionProfile {
id: number;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
stateVersion: number;
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
const SESS_BASE = `${BASE}/browser-sessions`;
export async function listBrowserSessionProfiles(): Promise<BrowserSessionProfile[]> {
const r = await fetch(`${SESS_BASE}/profiles`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`);
return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles;
}
export async function createBrowserSessionProfile(
input: Partial<BrowserSessionProfile> & { label: string; startUrl: string },
): Promise<BrowserSessionProfile> {
const r = await fetch(`${SESS_BASE}/profiles`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
}
export async function startBrowserSessionLogin(id: number): Promise<{ sessionId: string; novncPath: string }> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/login`, { method: 'POST', credentials: 'same-origin' });
if (!r.ok) throw new Error((await r.text().catch(() => '')) || `startLogin: ${r.status}`);
return r.json();
}
export async function saveBrowserSession(id: number, sessionId: string): Promise<BrowserSessionProfile> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
}
export async function cancelBrowserSession(id: number, sessionId: string): Promise<void> {
await fetch(`${SESS_BASE}/profiles/${id}/cancel`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
}
export async function testBrowserSessionProfile(id: number): Promise<{
verdict: { expired: boolean; reason?: string };
finalUrl: string;
statusCode: number;
}> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/test`, { method: 'POST', credentials: 'same-origin' });
if (!r.ok) throw new Error(`testBrowserSession: ${r.status}`);
return r.json();
}
export async function deleteBrowserSessionProfile(id: number): Promise<void> {
const r = await fetch(`${SESS_BASE}/profiles/${id}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`);
}
// --- Reflection ---
export interface LatestReflectionForTask {
snapshotId: string;
outcome: string;
memoryChanges: number | null;
pieceEdited: boolean;
}
export async function getLatestReflectionForTask(
taskId: number,
): Promise<LatestReflectionForTask | null> {
const res = await fetch(`${BASE}/local/reflection/latest-for-task/${taskId}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`getLatestReflectionForTask: ${res.status}`);
const data = await res.json();
// API returns null body when no reflection exists
if (!data || !data.snapshotId) return null;
return data as LatestReflectionForTask;
}
// --- User Folder Pets ---
export interface PetSettings {
enabled: boolean;
activePetId: string | null;
size: 32 | 48 | 64 | 80;
position: 'bottom-right';
sound: boolean;
reducedMotion: boolean;
toolSparkEnabled: boolean;
workerPets: Record<string, string>;
}
export interface WorkerInfo {
id: string;
endpoint: string | null;
model: string | null;
roles: string[];
enabled: boolean;
/** True if this worker fronts an LLM gateway / proxy (Phase A). */
proxy?: boolean;
/** Proxy implementation; only 'litellm' is currently shipped. */
proxyType?: 'litellm';
}
export async function fetchWorkers(): Promise<WorkerInfo[]> {
const res = await fetch('/api/workers', { credentials: 'include' });
if (!res.ok) return [];
const data = await res.json() as { workers?: WorkerInfo[] };
return data.workers ?? [];
}
export interface BackendInfo {
id: string;
model: string | null;
online: boolean;
}
export interface WorkerBackendsResponse {
source: 'direct' | 'proxy';
proxyType?: 'litellm';
backends: BackendInfo[];
/** Set when the proxy probe failed (network error, 5xx). UI renders degraded. */
error?: string;
}
export async function fetchWorkerBackends(workerId: string): Promise<WorkerBackendsResponse> {
const res = await fetch(`/api/workers/${encodeURIComponent(workerId)}/backends`, {
credentials: 'include',
});
if (!res.ok && res.status !== 502) {
// 502 still carries a typed payload from the server.
return { source: 'direct', backends: [], error: `HTTP ${res.status}` };
}
return await res.json() as WorkerBackendsResponse;
}
export interface PetSummary {
id: string;
name: string;
description: string | null;
spriteFile: string | null;
previewFile: string | null;
frameWidth: number | null;
frameHeight: number | null;
gridCols: number | null;
gridRows: number | null;
updatedAt: string;
}
export interface PetDetail extends PetSummary {
manifest: Record<string, unknown>;
}
export interface PetsResponse {
pets: PetSummary[];
settings: PetSettings;
}
const PETS_BASE = '/api/users/me/pets';
export function petAssetUrl(petId: string, file: string): string {
return `${PETS_BASE}/${encodeURIComponent(petId)}/assets/${encodeURIComponent(file)}`;
}
export async function fetchPets(): Promise<PetsResponse> {
const res = await fetch(PETS_BASE, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pets');
return data;
}
export async function fetchPet(petId: string): Promise<PetDetail> {
const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, { credentials: 'include' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pet');
return data.pet;
}
export async function importPet(file: File, options: { petId?: string; overwrite?: boolean } = {}): Promise<PetDetail> {
const params = new URLSearchParams();
params.set('filename', file.name);
if (options.petId) params.set('petId', options.petId);
if (options.overwrite) params.set('overwrite', 'true');
const res = await fetch(`${PETS_BASE}/import?${params.toString()}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/zip' },
body: await file.arrayBuffer(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to import pet');
return data.pet;
}
export async function deletePet(petId: string): Promise<void> {
const res = await fetch(`${PETS_BASE}/${encodeURIComponent(petId)}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete pet');
}
export async function updatePetSettings(patch: Partial<PetSettings>): Promise<PetSettings> {
const res = await fetch(`${PETS_BASE}/settings`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update pet settings');
return data.settings;
}
// ── Side Info Panel ────────────────────────────────────────────────────────
export type DashboardWidgetKind = 'markdown' | 'node-status';
export interface DashboardWidget {
id: number;
userId: string;
slug: string;
title: string;
kind: DashboardWidgetKind;
markdownContent: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface NodeStatus {
nodeId: string;
workerId: string;
source: 'direct' | 'proxy';
online: boolean;
busy: boolean;
busySlots: number;
totalSlots: number;
loadedModel: string | null;
throughputTps: number | null;
lastSeen: string;
lastProbeError?: string;
}
export interface WorkerStatusBackendRow {
id: string;
state: 'idle' | 'running';
busySlots: number;
totalSlots: number;
online: boolean | null;
}
export interface WorkerStatusRow {
id: string;
name: string;
roles: string[];
state: 'idle' | 'running';
/** True when this row represents a `proxy: true` worker. */
proxy: boolean;
/** Slot pressure from BackendStatusRegistry. Populated for direct workers with a registry probe row. */
busySlots?: number;
totalSlots?: number;
online?: boolean;
/** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
backends?: WorkerStatusBackendRow[];
}
export async function fetchDashboardWidgets(): Promise<DashboardWidget[]> {
const res = await fetch('/api/local/dashboard/widgets');
if (!res.ok) throw new Error(`Failed to list dashboard widgets: ${res.status}`);
const body = await res.json();
return body.widgets;
}
export async function createDashboardWidget(input: {
slug: string;
title: string;
content?: string;
kind?: DashboardWidgetKind;
}): Promise<DashboardWidget> {
const res = await fetch('/api/local/dashboard/widgets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Failed to create widget: ${res.status} ${await res.text()}`);
return (await res.json()).widget;
}
export async function updateDashboardWidget(id: number, patch: {
title?: string;
content?: string;
}): Promise<DashboardWidget> {
const res = await fetch(`/api/local/dashboard/widgets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`Failed to update widget: ${res.status} ${await res.text()}`);
return (await res.json()).widget;
}
export async function deleteDashboardWidget(id: number): Promise<void> {
const res = await fetch(`/api/local/dashboard/widgets/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`Failed to delete widget: ${res.status}`);
}
export async function reorderDashboardWidgets(ids: number[]): Promise<void> {
const res = await fetch('/api/local/dashboard/widgets/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error(`Failed to reorder widgets: ${res.status}`);
}
export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
const res = await fetch('/api/local/dashboard/workers');
if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`);
return (await res.json()).workers;
}
/** Thrown by fetchNodeStatus when the registry is not configured (HTTP 503). */
export class NodeStatusUnavailableError extends Error {
constructor() {
super('node-status registry not configured');
this.name = 'NodeStatusUnavailableError';
}
}
export async function fetchNodeStatus(): Promise<NodeStatus[]> {
const res = await fetch('/api/local/dashboard/node-status');
if (!res.ok) {
// 503 = registry not configured (e.g. legacy install). Surface as an
// error so the React Query hook can back off polling instead of
// hammering the server every 5s indefinitely. The hook turns this
// into an empty list for rendering.
if (res.status === 503) throw new NodeStatusUnavailableError();
throw new Error(`Failed to list node status: ${res.status}`);
}
return (await res.json()).nodes;
}
// ── AAO Gateway: virtual key admin (Phase 2a + 2b) ──────────────────────
//
// Talks to /api/admin/gateway/keys/* — requires admin role. The raw
// bearer key is returned ONCE from POST / and POST /:id/rotate; UI must
// surface it to the user immediately and not expose it again.
export interface GatewayKey {
id: string;
object: 'gateway.key';
keyPrefix: string;
team: string;
allowedModels: string[] | null;
source: 'admin' | 'config-import';
createdAt: string;
createdBy: string | null;
revokedAt: string | null;
revokedBy: string | null;
lastUsedAt: string | null;
tokensBudget: number | null;
rateLimitRpm: number | null;
/** Only present on POST / rotate responses. */
key?: string;
}
export interface GatewayKeyUsageResponse {
keyId: string;
currentPeriod: string;
tokensIn: number;
tokensOut: number;
tokensTotal: number;
tokensBudget: number | null;
remaining: number | null;
requestsThisMonth: number;
rateLimitRpm: number | null;
// Phase 3a F9: `rateRecentRequests` removed. The field was always
// null because the admin process doesn't own the gateway's live
// RateLimiter. Phase 3b/3c may re-add it via gateway IPC.
history: Array<{ period: string; tokensIn: number; tokensOut: number; requests: number }>;
}
export async function listGatewayKeys(params?: { team?: string; activeOnly?: boolean }): Promise<GatewayKey[]> {
const q = new URLSearchParams();
if (params?.team) q.set('team', params.team);
if (params?.activeOnly) q.set('activeOnly', 'true');
const qs = q.toString();
const res = await fetch(`/api/admin/gateway/keys${qs ? `?${qs}` : ''}`);
if (!res.ok) throw new Error(`Failed to list gateway keys: ${res.status}`);
return (await res.json()).keys;
}
export async function createGatewayKey(input: {
team: string;
allowedModels?: string[];
tokensBudget?: number | null;
rateLimitRpm?: number | null;
}): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to create gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function getGatewayKey(id: string): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`Failed to get gateway key: ${res.status}`);
return res.json();
}
export async function patchGatewayKey(
id: string,
patch: {
tokensBudget?: number | null;
rateLimitRpm?: number | null;
allowedModels?: string[] | null;
},
): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to update gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function revokeGatewayKey(id: string): Promise<void> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to revoke gateway key (${res.status}): ${text}`);
}
}
export async function rotateGatewayKey(id: string): Promise<GatewayKey> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/rotate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to rotate gateway key (${res.status}): ${text}`);
}
return res.json();
}
export async function deleteGatewayKey(id: string): Promise<void> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to delete gateway key (${res.status}): ${text}`);
}
}
export async function getGatewayKeyUsage(id: string): Promise<GatewayKeyUsageResponse> {
const res = await fetch(`/api/admin/gateway/keys/${encodeURIComponent(id)}/usage`);
if (!res.ok) throw new Error(`Failed to get gateway key usage: ${res.status}`);
return res.json();
}
// ============================================================
// Gateway Server status (Phase 3c) — read-only admin endpoint.
// Drives the Settings → Gateway Server badge and error list.
// ============================================================
export type GatewayServerState =
| 'unavailable'
| 'disabled'
| 'starting'
| 'running'
| 'stopping'
| 'misconfigured';
export interface GatewayServerStatus {
state: GatewayServerState;
/** Desired-enabled flag read from current config. Null when no ConfigManager. */
enabled: boolean | null;
/** Validation errors that prevented the gateway from starting. */
errors: string[];
mounted: boolean;
sharedPort: number;
/** Only present when state==='unavailable'. */
message?: string;
}
export async function getGatewayServerStatus(): Promise<GatewayServerStatus> {
const res = await fetch('/api/admin/gateway/status');
if (!res.ok) throw new Error(`Failed to get gateway status: ${res.status}`);
return res.json();
}
// ── Skills API ──────────────────────────────────────────────────────────
export interface SkillSummary {
name: string;
description: string;
triggers: string[];
source: 'system' | 'user';
hasDir: boolean;
}
export interface SkillDetail extends SkillSummary {
content: string;
files: string[];
findings: Array<{ severity: 'medium' | 'high'; pattern: string; match: string; line: number; file?: string }>;
maxSeverity: 'high' | 'medium' | 'none';
}
export async function fetchSkills(scope?: string): Promise<SkillSummary[]> {
const params = scope ? `?scope=${scope}` : '';
const res = await fetch(`/api/skills${params}`);
if (!res.ok) throw new Error('Failed to fetch skills');
const data = await res.json();
return data.skills;
}
export async function fetchSkillDetail(name: string, scope?: string): Promise<SkillDetail> {
const params = scope ? `?scope=${scope}` : '';
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${params}`);
if (!res.ok) throw new Error('Skill not found');
return res.json();
}
export async function createSkill(name: string, content: string, scope: string): Promise<{ name: string; severity: string; findings: any[] }> {
const res = await fetch('/api/skills', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content, scope }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
export async function updateSkill(name: string, content: string, scope: string): Promise<any> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
export async function deleteSkill(name: string, scope: string): Promise<void> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
}
export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[]): Promise<any> {
const res = await fetch('/api/skills/install-from-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, scope, selectedSkills }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
return res.json();
}
// ── Notifications V2 (Web Push) ───────────────────────────────────────
export type NotifyEventType = 'running' | 'succeeded' | 'failed' | 'waiting_human';
export interface NotificationPrefsDTO {
userId: string;
enabled: boolean;
events: Record<NotifyEventType, boolean>;
includeDetails: boolean;
v1Migrated: boolean;
updatedAt: string;
}
export interface NotificationPrefsInput {
enabled?: boolean;
events?: Partial<Record<NotifyEventType, boolean>>;
includeDetails?: boolean;
}
export interface PushSubscriptionPublic {
id: string;
endpointHost: string;
userAgent: string | null;
createdAt: string;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number;
}
export interface VapidPublicKeyDTO {
publicKey: string;
keyId: string;
}
async function notificationsJsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
const data = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback);
return data as T;
}
export async function fetchVapidPublicKey(): Promise<VapidPublicKeyDTO> {
const res = await fetch(`${BASE}/notifications/vapid-public-key`);
return notificationsJsonOrThrow(res, 'failed to fetch VAPID key');
}
export async function listPushSubscriptions(): Promise<PushSubscriptionPublic[]> {
const res = await fetch(`${BASE}/notifications/subscriptions`);
const data = await notificationsJsonOrThrow<{ subscriptions: PushSubscriptionPublic[] }>(
res, 'failed to list subscriptions',
);
return data.subscriptions;
}
export async function postPushSubscription(input: {
endpoint: string;
p256dh: string;
auth: string;
userAgent?: string;
}): Promise<{ id: string }> {
const res = await fetch(`${BASE}/notifications/subscriptions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return notificationsJsonOrThrow(res, 'failed to register subscription');
}
export async function deletePushSubscription(id: string): Promise<void> {
const res = await fetch(`${BASE}/notifications/subscriptions/${id}`, { method: 'DELETE' });
await notificationsJsonOrThrow(res, 'failed to delete subscription');
}
export async function fetchNotificationPrefs(): Promise<NotificationPrefsDTO> {
const res = await fetch(`${BASE}/notifications/preferences`);
return notificationsJsonOrThrow(res, 'failed to fetch preferences');
}
export async function updateNotificationPrefs(
input: NotificationPrefsInput,
): Promise<NotificationPrefsDTO> {
const res = await fetch(`${BASE}/notifications/preferences`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return notificationsJsonOrThrow(res, 'failed to update preferences');
}
export async function migrateLocalStoragePrefs(
input: NotificationPrefsInput,
): Promise<{ ok: boolean; prefs: NotificationPrefsDTO } | { alreadyMigrated: true }> {
const res = await fetch(`${BASE}/notifications/preferences/migrate-from-localstorage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (res.status === 409) return { alreadyMigrated: true };
return notificationsJsonOrThrow(res, 'failed to migrate preferences');
}
export async function postTestNotification(): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to send test notification');
}
// ============================================================
// LLM usage dashboard (per-user, gateway + direct).
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
export interface UsageCounters {
tokensIn: number;
tokensOut: number;
requests: number;
}
export interface UsageBucket {
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
gateway: UsageCounters;
direct: UsageCounters;
}
export interface UsageByUser extends UsageCounters {
userId: string;
/** Resolved display name (real users); 'local' / 'system' for sentinels. */
displayName: string;
}
export interface UsageDailyResponse {
from: string;
to: string;
granularity: 'day' | 'week' | 'month';
scope: 'all' | 'self';
series: UsageBucket[];
totals: { gateway: UsageCounters; direct: UsageCounters };
byUser?: UsageByUser[]; // admin / local mode only
}
export async function getUsageDaily(params: {
from?: string;
to?: string;
granularity?: 'day' | 'week' | 'month';
}): Promise<UsageDailyResponse> {
const qs = new URLSearchParams();
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.granularity) qs.set('granularity', params.granularity);
const q = qs.toString();
const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
return res.json();
}