import { resolve } from "node:path"; import type { RunStatus } from "./run-persistence.js"; import { STANDALONE_PROTOCOL_VERSION } from "./standalone-contract.js"; import type { ResumeStandaloneRunRequest, StandaloneRunState, StandaloneRuntimeOverview, StandaloneRuntimeState, StartStandaloneRunRequest, } from "./standalone-runtime.js"; import { type RuntimeDescriptor, readRuntimeDescriptor, standaloneRuntimeDescriptorPath } from "./standalone-server.js"; const SETTLED_STATUSES: ReadonlySet = new Set(["paused", "completed", "failed", "aborted"]); export class StandaloneRuntimeClient { readonly descriptor: RuntimeDescriptor; private readonly baseUrl: string; constructor(descriptor: RuntimeDescriptor) { this.descriptor = { ...descriptor }; this.baseUrl = `http://${formatUrlHost(descriptor.host)}:${descriptor.port}`; } async health(signal?: AbortSignal): Promise<{ ok: boolean; protocolVersion: number; pid: number; cwd: string; startedAt: string; }> { return this.request("/api/health", { signal }); } async state(signal?: AbortSignal): Promise { return this.request("/api/state", { signal }); } async overview(signal?: AbortSignal): Promise { return this.request("/api/overview", { signal }); } async getRun(runId: string, signal?: AbortSignal): Promise { return this.request(`/api/runs/${encodeURIComponent(runId)}`, { signal }); } async startRun(request: StartStandaloneRunRequest, signal?: AbortSignal): Promise<{ runId: string }> { return this.request("/api/runs", { method: "POST", body: request, signal, }); } async pause(runId: string, signal?: AbortSignal): Promise { const response = await this.request<{ ok: boolean }>(`/api/runs/${encodeURIComponent(runId)}/pause`, { method: "POST", body: {}, signal, acceptStatuses: [202, 409], }); return response.ok; } async resume(runId: string, request: ResumeStandaloneRunRequest = {}, signal?: AbortSignal): Promise { const response = await this.request<{ ok: boolean }>(`/api/runs/${encodeURIComponent(runId)}/resume`, { method: "POST", body: request, signal, acceptStatuses: [202, 409], }); return response.ok; } async stop(runId: string, signal?: AbortSignal): Promise { const response = await this.request<{ ok: boolean }>(`/api/runs/${encodeURIComponent(runId)}/stop`, { method: "POST", body: {}, signal, acceptStatuses: [202, 409], }); return response.ok; } async delete(runId: string, signal?: AbortSignal): Promise { const response = await this.request<{ ok: boolean }>(`/api/runs/${encodeURIComponent(runId)}`, { method: "DELETE", signal, acceptStatuses: [200, 404], }); return response.ok; } async respondToCheckpoint(checkpointId: string, value: unknown, signal?: AbortSignal): Promise { const response = await this.request<{ ok: boolean }>( `/api/checkpoints/${encodeURIComponent(checkpointId)}/respond`, { method: "POST", body: { value }, signal, acceptStatuses: [200, 404], }, ); return response.ok; } async waitForSettled( runId: string, options: { signal?: AbortSignal; pollIntervalMs?: number; onUpdate?: (run: StandaloneRunState) => void } = {}, ): Promise { const pollIntervalMs = Math.max(50, options.pollIntervalMs ?? 500); let previousSignature = ""; while (true) { if (options.signal?.aborted) { throw options.signal.reason ?? new Error("Waiting for workflow was aborted."); } const run = await this.getRun(runId, options.signal); const signature = `${run.status}:${run.currentPhase ?? ""}:${run.agents.length}:${run.updatedAt}`; if (signature !== previousSignature) { previousSignature = signature; options.onUpdate?.(run); } if (SETTLED_STATUSES.has(run.status)) return run; await abortableDelay(pollIntervalMs, options.signal); } } dashboardUrl(): string { return `${this.baseUrl}/?token=${encodeURIComponent(this.descriptor.authToken)}`; } private async request( path: string, options: { method?: string; body?: unknown; signal?: AbortSignal; acceptStatuses?: number[]; } = {}, ): Promise { const response = await fetch(`${this.baseUrl}${path}`, { method: options.method ?? "GET", headers: { authorization: `Bearer ${this.descriptor.authToken}`, ...(options.body === undefined ? {} : { "content-type": "application/json" }), }, body: options.body === undefined ? undefined : JSON.stringify(options.body), signal: options.signal, }); const accepted = options.acceptStatuses ?? [200, 202]; const text = await response.text(); let payload: unknown; try { payload = text ? JSON.parse(text) : undefined; } catch { payload = text; } if (!accepted.includes(response.status)) { const message = payload && typeof payload === "object" && typeof (payload as { error?: unknown }).error === "string" ? (payload as { error: string }).error : `Workflow runtime request failed (${response.status}).`; throw new Error(message); } return payload as T; } } export async function discoverStandaloneRuntime( cwd: string, options: { descriptorPath?: string; timeoutMs?: number } = {}, ): Promise { const projectCwd = resolve(cwd); const descriptorPath = options.descriptorPath ?? standaloneRuntimeDescriptorPath(projectCwd); const descriptor = readRuntimeDescriptor(projectCwd, descriptorPath); if (!descriptor || resolve(descriptor.cwd) !== projectCwd) return null; const client = new StandaloneRuntimeClient(descriptor); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(100, options.timeoutMs ?? 750)); timer.unref?.(); try { const health = await client.health(controller.signal); return health.ok && health.protocolVersion === STANDALONE_PROTOCOL_VERSION && resolve(health.cwd) === projectCwd ? client : null; } catch { return null; } finally { clearTimeout(timer); } } function formatUrlHost(host: string): string { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } function abortableDelay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolveDelay, rejectDelay) => { if (signal?.aborted) { rejectDelay(signal.reason ?? new Error("Aborted.")); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolveDelay(); }, ms); const onAbort = () => { clearTimeout(timer); rejectDelay(signal?.reason ?? new Error("Aborted.")); }; signal?.addEventListener("abort", onAbort, { once: true }); }); }