Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* fetch wrapper: JSON request/response, unified errors -> ApiError,
|
||||
* same-origin cookie auth (credentials: same-origin; CSRF relies on SameSite=Lax + JSON
|
||||
* Content-Type, see server README).
|
||||
*
|
||||
* When the session becomes invalid (server 401, e.g. database rebuilt, cookie expired),
|
||||
* notifies AuthProvider to clear the current user, letting the route guard redirect to the
|
||||
* login page — instead of each page popping its own "unauthorized" error.
|
||||
*/
|
||||
import { S } from "../lib/strings";
|
||||
|
||||
/** Unified API error: carries the HTTP status code and server error code (server error body {error:{code,message}}). */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session-invalidation callback (registered by AuthProvider; not triggered by 401s from the login/register endpoints themselves). */
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
/** 401/409 from auth endpoints themselves are business failures (e.g. wrong password) and must not trigger a global logout. */
|
||||
function isAuthEndpoint(path: string): boolean {
|
||||
return path.startsWith("/api/auth/");
|
||||
}
|
||||
|
||||
export interface ApiFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
/** JSON request body (auto-serialized with Content-Type: application/json). */
|
||||
body?: unknown;
|
||||
/** Query parameters (undefined values are skipped). */
|
||||
query?: Record<string, string | number | undefined>;
|
||||
}
|
||||
|
||||
/** Makes an API request; non-2xx responses uniformly throw ApiError; 204/empty body returns undefined. */
|
||||
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
let url = path;
|
||||
if (options.query) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(options.query)) {
|
||||
if (value !== undefined) params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: options.method ?? "GET",
|
||||
credentials: "same-origin",
|
||||
...(options.body !== undefined
|
||||
? {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(options.body),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "network_error", S.errors.networkError);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let code = "http_error";
|
||||
let message: string = S.common.unknownError;
|
||||
try {
|
||||
const body = (await response.json()) as { error?: { code?: string; message?: string } };
|
||||
if (body.error?.code) code = body.error.code;
|
||||
if (body.error?.message) message = body.error.message;
|
||||
} catch {
|
||||
// Non-JSON error body: fall back to the default message.
|
||||
}
|
||||
if (response.status === 401 && !isAuthEndpoint(path)) onUnauthorized?.();
|
||||
throw new ApiError(response.status, code, message);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
const text = await response.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* API endpoint wrappers: one function per API.
|
||||
* DTO types come from @prismshadow/penguin-server/api (**type import only**, resolved via
|
||||
* tsconfig paths to the server contract file types.ts; must not be a value import — server
|
||||
* code must not enter the browser bundle).
|
||||
*/
|
||||
import type {
|
||||
AdminPasswordResetRequest,
|
||||
AdminUserCreateRequest,
|
||||
AdminUserCreateResponse,
|
||||
AdminUsersResponse,
|
||||
AgentConfigResponse,
|
||||
AgentConfigUpdateRequest,
|
||||
AgentCreateRequest,
|
||||
AgentCreateResponse,
|
||||
AgentImportRequest,
|
||||
AgentImportResponse,
|
||||
AgentSkillsResponse,
|
||||
AgentsResponse,
|
||||
AgentTracesResponse,
|
||||
ApprovalDecisionRequest,
|
||||
AuthLoginRequest,
|
||||
AuthResponse,
|
||||
BenchmarksResponse,
|
||||
DirListResponse,
|
||||
FilesStatRequest,
|
||||
FilesStatResponse,
|
||||
MeResponse,
|
||||
MemberAddRequest,
|
||||
MemberAddResponse,
|
||||
MembersResponse,
|
||||
MessagesResponse,
|
||||
ModelsResponse,
|
||||
ModelsUpdateRequest,
|
||||
ModelTestRequest,
|
||||
ModelTestResponse,
|
||||
PasswordChangeRequest,
|
||||
PrefsResponse,
|
||||
ProjectCreateRequest,
|
||||
ProjectCreateResponse,
|
||||
ProjectsResponse,
|
||||
ScheduleItem,
|
||||
SchedulesResponse,
|
||||
ScheduleUpsertRequest,
|
||||
SessionCreateRequest,
|
||||
SessionCreateResponse,
|
||||
SessionPatchRequest,
|
||||
SessionResponse,
|
||||
SessionsResponse,
|
||||
SessionTracesResponse,
|
||||
SkillInstallRequest,
|
||||
SkillLibraryResponse,
|
||||
TaskCreateRequest,
|
||||
TaskCreateResponse,
|
||||
TraceAnalysisResponse,
|
||||
TraceEventsResponse,
|
||||
UiPrefs,
|
||||
UsageGroupBy,
|
||||
UsageResponse,
|
||||
VaultResponse,
|
||||
VaultUpdateRequest,
|
||||
WorkspaceFilesResponse,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
// Auth & user -----------------------------------------------------------------
|
||||
|
||||
export const login = (body: AuthLoginRequest) =>
|
||||
apiFetch<AuthResponse>("/api/auth/login", { method: "POST", body });
|
||||
|
||||
export const logout = () => apiFetch<void>("/api/auth/logout", { method: "POST", body: {} });
|
||||
|
||||
export const getMe = () => apiFetch<MeResponse>("/api/me");
|
||||
|
||||
export const changePassword = (body: PasswordChangeRequest) =>
|
||||
apiFetch<void>("/api/me/password", { method: "PUT", body });
|
||||
|
||||
export const getPrefs = () => apiFetch<PrefsResponse>("/api/me/prefs");
|
||||
|
||||
export const putPrefs = (prefs: UiPrefs) =>
|
||||
apiFetch<PrefsResponse>("/api/me/prefs", { method: "PUT", body: prefs });
|
||||
|
||||
// Admin user management (admin only) -----------------------------------------------------
|
||||
|
||||
export const adminListUsers = () => apiFetch<AdminUsersResponse>("/api/admin/users");
|
||||
|
||||
export const adminCreateUser = (body: AdminUserCreateRequest) =>
|
||||
apiFetch<AdminUserCreateResponse>("/api/admin/users", { method: "POST", body });
|
||||
|
||||
export const adminResetPassword = (userId: string, body: AdminPasswordResetRequest) =>
|
||||
apiFetch<void>(`/api/admin/users/${encodeURIComponent(userId)}/password`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
export const adminDeleteUser = (userId: string) =>
|
||||
apiFetch<void>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
|
||||
|
||||
// Project & members --------------------------------------------------------------
|
||||
|
||||
export const listProjects = () => apiFetch<ProjectsResponse>("/api/projects");
|
||||
|
||||
export const createProject = (body: ProjectCreateRequest) =>
|
||||
apiFetch<ProjectCreateResponse>("/api/projects", { method: "POST", body });
|
||||
|
||||
export const deleteProject = (projectId: string) =>
|
||||
apiFetch<void>(`/api/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
|
||||
export const listMembers = (projectId: string) =>
|
||||
apiFetch<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`);
|
||||
|
||||
export const addMember = (projectId: string, body: MemberAddRequest) =>
|
||||
apiFetch<MemberAddResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
export const removeMember = (projectId: string, username: string) =>
|
||||
apiFetch<void>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(username)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
// Model configuration -------------------------------------------------------------------
|
||||
|
||||
export const getModels = (projectId: string) =>
|
||||
apiFetch<ModelsResponse>(`/api/projects/${encodeURIComponent(projectId)}/models`);
|
||||
|
||||
export const putModels = (projectId: string, body: ModelsUpdateRequest) =>
|
||||
apiFetch<ModelsResponse>(`/api/projects/${encodeURIComponent(projectId)}/models`, {
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
|
||||
/** Connectivity test: model reference (provider, modelId) is passed in the request body (may include an unsaved apiKey / baseUrl). */
|
||||
export const testModel = (projectId: string, body: ModelTestRequest) =>
|
||||
apiFetch<ModelTestResponse>(`/api/projects/${encodeURIComponent(projectId)}/models/test`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
// Vault environment variables (Agent-level) -------------------------------------------------------
|
||||
|
||||
export const getVault = (projectId: string, agentId: string) =>
|
||||
apiFetch<VaultResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/vault`,
|
||||
);
|
||||
|
||||
export const putVault = (projectId: string, agentId: string, body: VaultUpdateRequest) =>
|
||||
apiFetch<VaultResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/vault`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
|
||||
// Agent & its configuration ----------------------------------------------------------------
|
||||
|
||||
export const listAgents = (projectId: string) =>
|
||||
apiFetch<AgentsResponse>(`/api/projects/${encodeURIComponent(projectId)}/agents`);
|
||||
|
||||
export const createAgent = (projectId: string, body: AgentCreateRequest) =>
|
||||
apiFetch<AgentCreateResponse>(`/api/projects/${encodeURIComponent(projectId)}/agents`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
export const getAgentConfig = (projectId: string, agentId: string) =>
|
||||
apiFetch<AgentConfigResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/config`,
|
||||
);
|
||||
|
||||
export const putAgentConfig = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
body: AgentConfigUpdateRequest,
|
||||
) =>
|
||||
apiFetch<AgentConfigResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/config`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
|
||||
export const getAgentTraces = (projectId: string, agentId: string) =>
|
||||
apiFetch<AgentTracesResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces`,
|
||||
);
|
||||
|
||||
// Session ---------------------------------------------------------------------
|
||||
|
||||
export const listSessions = (projectId: string, agentId: string) =>
|
||||
apiFetch<SessionsResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
|
||||
);
|
||||
|
||||
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
|
||||
export const listDirs = (projectId: string, path = "") =>
|
||||
apiFetch<DirListResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/dirs?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
|
||||
export const createSession = (projectId: string, agentId: string, body: SessionCreateRequest) =>
|
||||
apiFetch<SessionCreateResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
|
||||
export const getSession = (sessionId: string) =>
|
||||
apiFetch<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
|
||||
export const patchSession = (sessionId: string, body: SessionPatchRequest) =>
|
||||
apiFetch<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
});
|
||||
|
||||
export const deleteSession = (sessionId: string) =>
|
||||
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
|
||||
|
||||
export const getMessages = (sessionId: string) =>
|
||||
apiFetch<MessagesResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/messages`);
|
||||
|
||||
// Task execution, approval, abort, compaction ------------------------------------------------------
|
||||
|
||||
export const postTask = (sessionId: string, body: TaskCreateRequest) =>
|
||||
apiFetch<TaskCreateResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/tasks`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
export const postApproval = (
|
||||
sessionId: string,
|
||||
toolCallId: string,
|
||||
body: ApprovalDecisionRequest,
|
||||
) =>
|
||||
apiFetch<void>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(toolCallId)}`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
|
||||
export const postAbort = (sessionId: string) =>
|
||||
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
export const postCompact = (sessionId: string) =>
|
||||
// Same shape as tasks: the response carries the actual current session_id (a new id after self-healing; the frontend updates its route accordingly).
|
||||
apiFetch<TaskCreateResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/compact`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
// Trace browsing & performance analysis -----------------------------------------------------------
|
||||
|
||||
export const getSessionTraces = (sessionId: string) =>
|
||||
apiFetch<SessionTracesResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/traces`);
|
||||
|
||||
export const getTraceEvents = (sessionId: string, index: number, offset: number, limit: number) =>
|
||||
apiFetch<TraceEventsResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/traces/${index}`, {
|
||||
query: { offset, limit },
|
||||
});
|
||||
|
||||
export const getTraceAnalysis = (sessionId: string, index: number) =>
|
||||
apiFetch<TraceAnalysisResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/traces/${index}/analysis`,
|
||||
);
|
||||
|
||||
// Agent-level Trace details (read-only, independent of sessions-table registration): the Trace
|
||||
// page's directory tree comes from an Agent-level scan (including subagent child Sessions and
|
||||
// Sessions created by the CLI); details go through the Agent-level endpoint to avoid 404s for
|
||||
// unregistered sessions.
|
||||
|
||||
export const getAgentTraceEvents = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
index: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
) =>
|
||||
apiFetch<TraceEventsResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
|
||||
`/traces/${encodeURIComponent(sessionId)}/${index}`,
|
||||
{ query: { offset, limit } },
|
||||
);
|
||||
|
||||
export const getAgentTraceAnalysis = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
index: number,
|
||||
) =>
|
||||
apiFetch<TraceAnalysisResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
|
||||
`/traces/${encodeURIComponent(sessionId)}/${index}/analysis`,
|
||||
);
|
||||
|
||||
// Usage statistics ----------------------------------------------------------------------
|
||||
|
||||
export const getUsage = (
|
||||
projectId: string,
|
||||
params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
groupBy: UsageGroupBy;
|
||||
agentId?: string;
|
||||
/** Model filters are given as a pair (only takes effect when both provider and modelId are supplied). */
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
},
|
||||
) =>
|
||||
apiFetch<UsageResponse>(`/api/projects/${encodeURIComponent(projectId)}/usage`, {
|
||||
query: {
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
groupBy: params.groupBy,
|
||||
agentId: params.agentId,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
},
|
||||
});
|
||||
|
||||
// Agent deletion & Workspace files --------------------------------------------------
|
||||
|
||||
export const deleteAgent = (projectId: string, agentId: string) =>
|
||||
apiFetch<void>(`/api/projects/${projectId}/agents/${agentId}`, { method: "DELETE" });
|
||||
|
||||
export const listWorkspaceFiles = (sessionId: string, path: string) =>
|
||||
apiFetch<WorkspaceFilesResponse>(`/api/sessions/${sessionId}/files`, { query: { path } });
|
||||
|
||||
/** File content URL (inline preview / download=1 triggers download; usable directly in <a>/<img>/fetch). */
|
||||
export const workspaceFileUrl = (sessionId: string, path: string, download = false): string =>
|
||||
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
|
||||
|
||||
export const uploadWorkspaceFile = (sessionId: string, path: string, dataBase64: string) =>
|
||||
apiFetch<void>(`/api/sessions/${sessionId}/files/content`, {
|
||||
method: "PUT",
|
||||
body: { dataBase64 },
|
||||
query: { path },
|
||||
});
|
||||
|
||||
/** Batch file-existence check (message file cards): both out-of-bounds and missing paths simply don't appear in `existing`; always returns 200. */
|
||||
export const statSessionFiles = (sessionId: string, paths: string[]) =>
|
||||
apiFetch<FilesStatResponse>(`/api/sessions/${sessionId}/files/stat`, {
|
||||
method: "POST",
|
||||
body: { paths } satisfies FilesStatRequest,
|
||||
});
|
||||
|
||||
// Scheduled tasks ----------------------------------------------------------------------
|
||||
|
||||
export const listSchedules = (projectId: string, agentId: string) =>
|
||||
apiFetch<SchedulesResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/schedules`,
|
||||
);
|
||||
|
||||
export const createSchedule = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
body: ScheduleUpsertRequest & { name: string },
|
||||
) =>
|
||||
apiFetch<ScheduleItem>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/schedules`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
|
||||
export const updateSchedule = (
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
name: string,
|
||||
body: ScheduleUpsertRequest,
|
||||
) =>
|
||||
apiFetch<ScheduleItem>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
|
||||
`/schedules/${encodeURIComponent(name)}`,
|
||||
{ method: "PUT", body },
|
||||
);
|
||||
|
||||
export const deleteSchedule = (projectId: string, agentId: string, name: string) =>
|
||||
apiFetch<void>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
|
||||
`/schedules/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
// Skill library & Agent-installed Skills ------------------------------------------------------
|
||||
|
||||
/** Skill library (available to any logged-in user): groups and metadata, excludes SKILL.md body content. */
|
||||
export const getSkillLibrary = () => apiFetch<SkillLibraryResponse>("/api/skills");
|
||||
|
||||
export const getAgentSkills = (projectId: string, agentId: string) =>
|
||||
apiFetch<AgentSkillsResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/skills`,
|
||||
);
|
||||
|
||||
/** Installs (if already installed, overwrites with the library content); 201 returns the Agent's latest installed list. */
|
||||
export const installAgentSkills = (projectId: string, agentId: string, names: string[]) =>
|
||||
apiFetch<AgentSkillsResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/skills`,
|
||||
{ method: "POST", body: { names } satisfies SkillInstallRequest },
|
||||
);
|
||||
|
||||
export const removeAgentSkill = (projectId: string, agentId: string, name: string) =>
|
||||
apiFetch<void>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
|
||||
`/skills/${encodeURIComponent(name)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
// Benchmark scoring (read-only display) -------------------------------------------------------
|
||||
|
||||
export const listBenchmarks = (projectId: string, agentId: string) =>
|
||||
apiFetch<BenchmarksResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/benchmarks`,
|
||||
);
|
||||
|
||||
// Agent State snapshot export / import ------------------------------------------------------
|
||||
|
||||
/** Snapshot bundle (tar.gz) download URL: the server sets Content-Disposition attachment, usable directly in <a download>. */
|
||||
export const agentExportUrl = (projectId: string, agentId: string): string =>
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/export`;
|
||||
|
||||
export const importAgent = (projectId: string, agentId: string, body: AgentImportRequest) =>
|
||||
apiFetch<AgentImportResponse>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/import`,
|
||||
{ method: "POST", body },
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* SSE (EventSource) wrapper.
|
||||
*
|
||||
* - OmniMessage uses the default event (no `event:` line); data is the message envelope as
|
||||
* raw JSON;
|
||||
* - Server events use `event: server_event` (approval_request / task_state / resync_required / hello);
|
||||
* - EventSource can't set custom request headers, so auth relies on same-origin cookies; on
|
||||
* disconnect, the browser auto-reconnects and attaches a `Last-Event-ID` header (the server
|
||||
* replays from its ring buffer; if the event was already evicted, it pushes resync_required
|
||||
* instead).
|
||||
* Docs: /docs/server-api § "Streaming (SSE)".
|
||||
*/
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type { ServerEvent } from "@prismshadow/penguin-server/api";
|
||||
|
||||
export interface StreamHandlers {
|
||||
/** A single OmniMessage (full/streaming/event, envelope as-is). */
|
||||
onOmniMessage: (msg: OmniMessage) => void;
|
||||
/** A single server event. */
|
||||
onServerEvent: (event: ServerEvent) => void;
|
||||
/** Connection established (including a successful auto-reconnect). */
|
||||
onOpen?: () => void;
|
||||
/**
|
||||
* Connection error. `closed` is true when the browser has deemed the connection fatally
|
||||
* broken and closed it (e.g. the handshake returned 401/403, so it won't auto-reconnect);
|
||||
* when false, the browser will auto-reconnect and no manual handling is needed.
|
||||
*/
|
||||
onError?: (closed: boolean) => void;
|
||||
}
|
||||
|
||||
export interface StreamConnection {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function subscribe(url: string, handlers: StreamHandlers): StreamConnection {
|
||||
const source = new EventSource(url);
|
||||
source.onmessage = (e: MessageEvent<string>) => {
|
||||
try {
|
||||
handlers.onOmniMessage(JSON.parse(e.data) as OmniMessage);
|
||||
} catch {
|
||||
// Ignore lines that fail to parse (the protocol guarantees single-line JSON data, so this shouldn't normally happen).
|
||||
}
|
||||
};
|
||||
source.addEventListener("server_event", (e: MessageEvent<string>) => {
|
||||
try {
|
||||
handlers.onServerEvent(JSON.parse(e.data) as ServerEvent);
|
||||
} catch {
|
||||
// Same as above.
|
||||
}
|
||||
});
|
||||
if (handlers.onOpen) source.onopen = handlers.onOpen;
|
||||
const { onError } = handlers;
|
||||
if (onError) source.onerror = () => onError(source.readyState === EventSource.CLOSED);
|
||||
return { close: () => source.close() };
|
||||
}
|
||||
|
||||
/** Subscribes to a Session's output stream (GET /api/sessions/:sessionId/stream). */
|
||||
export function openSessionStream(sessionId: string, handlers: StreamHandlers): StreamConnection {
|
||||
return subscribe(`/api/sessions/${encodeURIComponent(sessionId)}/stream`, handlers);
|
||||
}
|
||||
|
||||
/** Subscribes to the user-level server event stream (GET /api/events; reserved for scheduled-task notifications). */
|
||||
export function openUserEvents(handlers: StreamHandlers): StreamConnection {
|
||||
return subscribe("/api/events", handlers);
|
||||
}
|
||||
Reference in New Issue
Block a user