1329 lines
47 KiB
TypeScript
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();
|
|
}
|