SPB Git

spb/autoscholar

Public
5.0 KB · 171 lines typescript
1import { createHash } from "node:crypto";2import {3  ArtifactDescriptor,4  CompleteAttemptRequest,5  EnrollResponse,6  type HeartbeatRequest,7  HeartbeatResponse,8  LeaseRenewResponse,9  LeaseResponse,10  type NodeMetadata,11  type RunEvent,12  StageReport,13  WORKER_PROTOCOL_VERSION,14} from "@autoscholar/schemas";15import type { z } from "zod";1617export class ControlPlaneError extends Error {18  constructor(19    message: string,20    readonly status: number,21    readonly code?: string,22  ) {23    super(message);24  }25}2627export interface WorkerClientOptions {28  baseUrl: string;29  token?: string;30  fetchImpl?: typeof fetch;31  timeoutMs?: number;32  userAgent?: string;33}3435/** Worker-side HTTP client for the versioned MacLustr worker protocol (outbound-only). */36export class WorkerClient {37  private token: string | undefined;38  private readonly base: string;39  private readonly fetchImpl: typeof fetch;40  private readonly timeoutMs: number;41  private readonly userAgent: string;4243  constructor(opts: WorkerClientOptions) {44    this.base = opts.baseUrl.replace(/\/+$/, "");45    this.token = opts.token;46    this.fetchImpl = opts.fetchImpl ?? fetch;47    this.timeoutMs = opts.timeoutMs ?? 30_000;48    this.userAgent = opts.userAgent ?? `autoscholar-worker/protocol-${WORKER_PROTOCOL_VERSION}`;49  }5051  setToken(token: string): void {52    this.token = token;53  }5455  private async call<T>(56    method: string,57    path: string,58    body?: unknown,59    schema?: z.ZodType<T>,60    raw?: { buffer: Buffer; contentType: string },61  ): Promise<T> {62    const ctrl = new AbortController();63    const t = setTimeout(() => ctrl.abort(), this.timeoutMs);64    try {65      const headers: Record<string, string> = {66        "user-agent": this.userAgent,67        "x-protocol-version": String(WORKER_PROTOCOL_VERSION),68      };69      if (this.token) headers.authorization = `Bearer ${this.token}`;70      let payload: BodyInit | undefined;71      if (raw) {72        headers["content-type"] = raw.contentType;73        payload = new Blob([new Uint8Array(raw.buffer)]);74      } else if (body !== undefined) {75        headers["content-type"] = "application/json";76        payload = JSON.stringify(body);77      }78      const res = await this.fetchImpl(`${this.base}${path}`, {79        method,80        headers,81        body: payload,82        signal: ctrl.signal,83      });84      const text = await res.text();85      let json: unknown = null;86      try {87        json = text ? JSON.parse(text) : null;88      } catch {89        json = null;90      }91      if (!res.ok) {92        const err = (json as { error?: { message?: string; code?: string } } | null)?.error;93        throw new ControlPlaneError(err?.message ?? `HTTP ${res.status} on ${path}`, res.status, err?.code);94      }95      return schema ? schema.parse(json) : (json as T);96    } finally {97      clearTimeout(t);98    }99  }100101  enroll(enrollmentSecret: string, name: string, node: NodeMetadata, lanIp?: string) {102    return this.call(103      "POST",104      "/api/internal/workers/enroll",105      { enrollmentSecret, name, node, lanIp },106      EnrollResponse,107    );108  }109  heartbeat(body: z.input<typeof HeartbeatRequest>) {110    return this.call("POST", "/api/internal/workers/heartbeat", body, HeartbeatResponse);111  }112  lease(profiles: string[]) {113    return this.call("POST", "/api/internal/workers/lease", { profiles }, LeaseResponse);114  }115  renew(runId: string, fencingToken: number) {116    return this.call(117      "POST",118      `/api/internal/workers/runs/${runId}/renew`,119      { fencingToken },120      LeaseRenewResponse,121    );122  }123  events(runId: string, fencingToken: number, events: RunEvent[]) {124    return this.call<{ ok: true; lastSeq: number }>("POST", `/api/internal/workers/runs/${runId}/events`, {125      fencingToken,126      events,127    });128  }129  stage(runId: string, report: z.input<typeof StageReport>) {130    return this.call<{ ok: true }>(131      "POST",132      `/api/internal/workers/runs/${runId}/stage`,133      StageReport.parse(report),134    );135  }136  async uploadArtifact(137    runId: string,138    fencingToken: number,139    descriptor: Omit<z.input<typeof ArtifactDescriptor>, "sha256" | "bytes">,140    buffer: Buffer,141  ) {142    const sha256 = createHash("sha256").update(buffer).digest("hex");143    const artifact = ArtifactDescriptor.parse({ ...descriptor, sha256, bytes: buffer.length });144    const q = new URLSearchParams({145      fencingToken: String(fencingToken),146      path: artifact.path,147      kind: artifact.kind,148      sha256,149      stage: artifact.stage,150      contentType: artifact.contentType,151      synthetic: artifact.synthetic ? "1" : "0",152    });153    return this.call<{ ok: true; artifactId: string; sha256: string; bytes: number }>(154      "PUT",155      `/api/internal/workers/runs/${runId}/artifacts?${q.toString()}`,156      undefined,157      undefined,158      { buffer, contentType: artifact.contentType },159    );160  }161  complete(runId: string, body: z.input<typeof CompleteAttemptRequest>) {162    return this.call<{ ok: true }>(163      "POST",164      `/api/internal/workers/runs/${runId}/complete`,165      CompleteAttemptRequest.parse(body),166    );167  }168}169170export { WORKER_PROTOCOL_VERSION };171
Files · f0cbc84bd5a5b107e87a3e5fad78a5d6e18fcc37
Menu

Theme