import { createHash } from "node:crypto"; import { ArtifactDescriptor, CompleteAttemptRequest, EnrollResponse, type HeartbeatRequest, HeartbeatResponse, LeaseRenewResponse, LeaseResponse, type NodeMetadata, type RunEvent, StageReport, WORKER_PROTOCOL_VERSION, } from "@autoscholar/schemas"; import type { z } from "zod"; export class ControlPlaneError extends Error { constructor( message: string, readonly status: number, readonly code?: string, ) { super(message); } } export interface WorkerClientOptions { baseUrl: string; token?: string; fetchImpl?: typeof fetch; timeoutMs?: number; userAgent?: string; } /** Worker-side HTTP client for the versioned MacLustr worker protocol (outbound-only). */ export class WorkerClient { private token: string | undefined; private readonly base: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; private readonly userAgent: string; constructor(opts: WorkerClientOptions) { this.base = opts.baseUrl.replace(/\/+$/, ""); this.token = opts.token; this.fetchImpl = opts.fetchImpl ?? fetch; this.timeoutMs = opts.timeoutMs ?? 30_000; this.userAgent = opts.userAgent ?? `autoscholar-worker/protocol-${WORKER_PROTOCOL_VERSION}`; } setToken(token: string): void { this.token = token; } private async call( method: string, path: string, body?: unknown, schema?: z.ZodType, raw?: { buffer: Buffer; contentType: string }, ): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), this.timeoutMs); try { const headers: Record = { "user-agent": this.userAgent, "x-protocol-version": String(WORKER_PROTOCOL_VERSION), }; if (this.token) headers.authorization = `Bearer ${this.token}`; let payload: BodyInit | undefined; if (raw) { headers["content-type"] = raw.contentType; payload = new Blob([new Uint8Array(raw.buffer)]); } else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); } const res = await this.fetchImpl(`${this.base}${path}`, { method, headers, body: payload, signal: ctrl.signal, }); const text = await res.text(); let json: unknown = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } if (!res.ok) { const err = (json as { error?: { message?: string; code?: string } } | null)?.error; throw new ControlPlaneError(err?.message ?? `HTTP ${res.status} on ${path}`, res.status, err?.code); } return schema ? schema.parse(json) : (json as T); } finally { clearTimeout(t); } } enroll(enrollmentSecret: string, name: string, node: NodeMetadata, lanIp?: string) { return this.call( "POST", "/api/internal/workers/enroll", { enrollmentSecret, name, node, lanIp }, EnrollResponse, ); } heartbeat(body: z.input) { return this.call("POST", "/api/internal/workers/heartbeat", body, HeartbeatResponse); } lease(profiles: string[]) { return this.call("POST", "/api/internal/workers/lease", { profiles }, LeaseResponse); } renew(runId: string, fencingToken: number) { return this.call( "POST", `/api/internal/workers/runs/${runId}/renew`, { fencingToken }, LeaseRenewResponse, ); } events(runId: string, fencingToken: number, events: RunEvent[]) { return this.call<{ ok: true; lastSeq: number }>("POST", `/api/internal/workers/runs/${runId}/events`, { fencingToken, events, }); } stage(runId: string, report: z.input) { return this.call<{ ok: true }>( "POST", `/api/internal/workers/runs/${runId}/stage`, StageReport.parse(report), ); } async uploadArtifact( runId: string, fencingToken: number, descriptor: Omit, "sha256" | "bytes">, buffer: Buffer, ) { const sha256 = createHash("sha256").update(buffer).digest("hex"); const artifact = ArtifactDescriptor.parse({ ...descriptor, sha256, bytes: buffer.length }); const q = new URLSearchParams({ fencingToken: String(fencingToken), path: artifact.path, kind: artifact.kind, sha256, stage: artifact.stage, contentType: artifact.contentType, synthetic: artifact.synthetic ? "1" : "0", }); return this.call<{ ok: true; artifactId: string; sha256: string; bytes: number }>( "PUT", `/api/internal/workers/runs/${runId}/artifacts?${q.toString()}`, undefined, undefined, { buffer, contentType: artifact.contentType }, ); } complete(runId: string, body: z.input) { return this.call<{ ok: true }>( "POST", `/api/internal/workers/runs/${runId}/complete`, CompleteAttemptRequest.parse(body), ); } } export { WORKER_PROTOCOL_VERSION };