SPB Git

spb/autoscholar

Public
25.0 KB · 705 lines typescript
1import { constantTimeEqual, hashToken, newWorkerToken } from "@autoscholar/auth";2import {3  artifacts,4  checkpoints,5  computeWorkers,6  type Db,7  jobLeases,8  projectInstructionVersions,9  providerUsage,10  researchRuns,11  resourceUsage,12  runAttempts,13  stageExecutions,14  type Tx,15  workerAssignments,16  workerHeartbeats,17} from "@autoscholar/db";18import { redact } from "@autoscholar/observability";19import {20  type ArtifactDescriptor,21  type NodeMetadata,22  type RunEvent,23  type RunManifest,24  STAGES,25  type StageKey,26  WORKER_PROTOCOL_VERSION,27  type WorkerStatus,28} from "@autoscholar/schemas";29import { type FilesystemStorage, runArtifactKey } from "@autoscholar/storage";30import { and, asc, desc, desc as descOrder, eq, inArray, isNull, lt, sql } from "drizzle-orm";31import { audit } from "./audit.js";32import { getEnv } from "./env.js";33import { AppError, conflict, forbidden, invalid, notFound, unauthorized } from "./errors.js";34import { appendEvent, latestCheckpoint, transition } from "./runs.js";35import { getSetting } from "./settings.js";3637export type WorkerRow = typeof computeWorkers.$inferSelect;3839export async function enrollWorker(40  db: Db,41  enrollmentSecret: string,42  name: string,43  node: NodeMetadata,44  lanIp: string | undefined,45  ip: string | null,46) {47  const env = getEnv();48  if (!env.WORKER_ENROLLMENT_SECRET || !constantTimeEqual(enrollmentSecret, env.WORKER_ENROLLMENT_SECRET)) {49    throw unauthorized("invalid enrollment secret");50  }51  if (node.protocolVersion !== WORKER_PROTOCOL_VERSION)52    throw new AppError(53      "protocol_mismatch",54      `worker protocol ${node.protocolVersion} != ${WORKER_PROTOCOL_VERSION}`,55      426,56    );57  const { token, tokenHash } = newWorkerToken();58  const [w] = await db59    .insert(computeWorkers)60    .values({61      name,62      tokenHash,63      status: "online",64      version: node.workerVersion,65      protocolVersion: node.protocolVersion,66      lanIp: lanIp ?? ip,67      nodeMeta: node,68      profiles: node.profiles,69      lastHeartbeatAt: new Date(),70      tokenRotatedAt: new Date(),71    })72    .onConflictDoUpdate({73      target: computeWorkers.name,74      set: {75        tokenHash,76        status: "online",77        version: node.workerVersion,78        protocolVersion: node.protocolVersion,79        lanIp: lanIp ?? ip,80        nodeMeta: node,81        profiles: node.profiles,82        lastHeartbeatAt: new Date(),83        tokenRotatedAt: new Date(),84        disabledAt: null,85      },86    })87    .returning();88  await audit(89    db,90    { type: "worker", id: w!.id, ip },91    "worker.enroll",92    { type: "worker", id: w!.id },93    { name, machineId: node.machineId, sandboxAvailable: node.sandboxAvailable },94  );95  return { workerId: w!.id, token, protocolVersion: WORKER_PROTOCOL_VERSION };96}9798export async function authenticateWorker(db: Db, bearer: string | null | undefined): Promise<WorkerRow> {99  if (!bearer?.startsWith("asw_")) throw unauthorized("worker token required");100  const [w] = await db101    .select()102    .from(computeWorkers)103    .where(eq(computeWorkers.tokenHash, hashToken(bearer)))104    .limit(1);105  if (!w) throw unauthorized("unknown worker token");106  if (w.disabledAt || w.status === "disabled") throw forbidden("worker disabled");107  if (w.status === "quarantined") throw forbidden(`worker quarantined: ${w.quarantineReason ?? ""}`);108  return w;109}110111export async function heartbeat(112  db: Db,113  worker: WorkerRow,114  body: {115    status: WorkerStatus;116    capacity: number;117    running: string[];118    load: Record<string, unknown>;119    node?: Partial<NodeMetadata>;120    leases: Array<{ runId: string; fencingToken: number }>;121  },122) {123  const env = getEnv();124  const status: WorkerStatus = worker.drainRequested ? "draining" : body.status;125  await db126    .update(computeWorkers)127    .set({128      status,129      capacity: body.capacity,130      running: body.running.length,131      lastHeartbeatAt: new Date(),132      nodeMeta: body.node ? { ...(worker.nodeMeta as object), ...body.node } : worker.nodeMeta,133    })134    .where(eq(computeWorkers.id, worker.id));135  await db136    .insert(workerHeartbeats)137    .values({ workerId: worker.id, load: body.load, running: body.running.length });138  // leases the worker thinks it holds but which are no longer its own (fencing) → tell it to stop139  const revokedLeases: string[] = [];140  const cancel: string[] = [];141  for (const l of body.leases) {142    const [lease] = await db.select().from(jobLeases).where(eq(jobLeases.runId, l.runId)).limit(1);143    if (!lease || lease.workerId !== worker.id || lease.fencingToken !== l.fencingToken)144      revokedLeases.push(l.runId);145    else {146      await db147        .update(jobLeases)148        .set({ lastHeartbeatAt: new Date(), expiresAt: new Date(Date.now() + env.LEASE_TTL_SECONDS * 1000) })149        .where(eq(jobLeases.runId, l.runId));150      const [run] = await db151        .select({ cancelRequested: researchRuns.cancelRequested })152        .from(researchRuns)153        .where(eq(researchRuns.id, l.runId))154        .limit(1);155      if (run?.cancelRequested) cancel.push(l.runId);156    }157  }158  return {159    ok: true as const,160    drain: !!worker.drainRequested,161    cancel,162    revokedLeases,163    leaseTtlSeconds: env.LEASE_TTL_SECONDS,164  };165}166167/** Lease the oldest queued run matching the worker's profiles. SKIP LOCKED makes concurrent workers safe. */168export async function leaseJob(db: Db, worker: WorkerRow, profiles: string[]) {169  const env = getEnv();170  if (worker.drainRequested || worker.status === "draining") return { job: null };171  if (env.DISPATCH_ENABLED !== 1 || (await getSetting(db, "dispatch.paused", false))) return { job: null };172  const node = worker.nodeMeta as Partial<NodeMetadata>;173  if (!node.sandboxAvailable) return { job: null }; // never dispatch to a worker that cannot enforce isolation174  return db.transaction(async (tx) => {175    const [run] = await tx176      .select()177      .from(researchRuns)178      .where(179        and(180          eq(researchRuns.status, "queued"),181          inArray(researchRuns.computeProfile, profiles.length ? profiles : ["standard"]),182        ),183      )184      .orderBy(asc(researchRuns.queuedAt))185      .limit(1)186      .for("update", { skipLocked: true });187    if (!run) return { job: null };188    const [existingLease] = await tx.select().from(jobLeases).where(eq(jobLeases.runId, run.id)).limit(1);189    if (existingLease && existingLease.expiresAt.getTime() > Date.now()) return { job: null };190    if (run.currentAttemptNo >= run.maxAttempts) {191      await transition(tx, run.id, "failed", {192        reason: `attempt limit (${run.maxAttempts}) reached`,193        actor: "scheduler",194      });195      return { job: null };196    }197    const attemptNo = run.currentAttemptNo + 1;198    const fencingToken = run.fencingToken + 1;199    const cp = await latestCheckpoint(tx, run.id);200    const resumeFromStage = (cp?.stage as StageKey | undefined) ?? null;201    const [attempt] = await tx202      .insert(runAttempts)203      .values({204        runId: run.id,205        attemptNo,206        workerId: worker.id,207        status: "leased",208        fencingToken,209        resumeFromStage,210      })211      .returning();212    const expiresAt = new Date(Date.now() + env.LEASE_TTL_SECONDS * 1000);213    await tx.delete(jobLeases).where(eq(jobLeases.runId, run.id));214    await tx215      .insert(jobLeases)216      .values({ runId: run.id, attemptId: attempt!.id, workerId: worker.id, fencingToken, expiresAt });217    await tx.insert(workerAssignments).values({ runId: run.id, attemptId: attempt!.id, workerId: worker.id });218    await tx219      .update(researchRuns)220      .set({ currentAttemptNo: attemptNo, fencingToken, updatedAt: new Date() })221      .where(eq(researchRuns.id, run.id));222    await transition(tx, run.id, "provisioning", {223      actor: `worker:${worker.name}`,224      attemptId: attempt!.id,225      workerId: worker.id,226      data: { attemptNo, fencingToken, resumeFromStage },227    });228    await tx229      .update(computeWorkers)230      .set({ running: sql`${computeWorkers.running} + 1` })231      .where(eq(computeWorkers.id, worker.id));232    const [iv] = await tx233      .select({ contentText: projectInstructionVersions.contentText })234      .from(projectInstructionVersions)235      .where(eq(projectInstructionVersions.id, run.instructionVersionId))236      .limit(1);237    return {238      job: {239        runId: run.id,240        attemptId: attempt!.id,241        attemptNo,242        fencingToken,243        leaseExpiresAt: expiresAt.toISOString(),244        manifest: run.manifest as RunManifest,245        instructionText: iv!.contentText,246        resumeFromStage,247        checkpoint: (cp?.data as Record<string, unknown> | undefined) ?? null,248        cancelRequested: run.cancelRequested,249      },250    };251  });252}253254/** Validates that the worker holds the current lease with the exact fencing token. */255export async function assertLease(tx: Tx | Db, runId: string, worker: WorkerRow, fencingToken: number) {256  const [lease] = await tx.select().from(jobLeases).where(eq(jobLeases.runId, runId)).limit(1);257  if (!lease || lease.workerId !== worker.id || lease.fencingToken !== fencingToken) {258    throw new AppError("lease_lost", "lease is not held by this worker (stale fencing token)", 409);259  }260  return lease;261}262263export async function renewLease(db: Db, worker: WorkerRow, runId: string, fencingToken: number) {264  const env = getEnv();265  const lease = await assertLease(db, runId, worker, fencingToken);266  const expiresAt = new Date(Date.now() + env.LEASE_TTL_SECONDS * 1000);267  await db268    .update(jobLeases)269    .set({ expiresAt, lastHeartbeatAt: new Date() })270    .where(eq(jobLeases.runId, lease.runId));271  const [run] = await db272    .select({ cancelRequested: researchRuns.cancelRequested, status: researchRuns.status })273    .from(researchRuns)274    .where(eq(researchRuns.id, runId))275    .limit(1);276  return {277    ok: true,278    cancelRequested: !!run?.cancelRequested || run?.status === "cancelling",279    leaseExpiresAt: expiresAt.toISOString(),280  };281}282283export async function ingestEvents(284  db: Db,285  worker: WorkerRow,286  runId: string,287  fencingToken: number,288  events: RunEvent[],289) {290  return db.transaction(async (tx) => {291    const lease = await assertLease(tx, runId, worker, fencingToken);292    let last = 0;293    for (const ev of events)294      last = await appendEvent(tx, runId, { ...redact(ev), attemptId: lease.attemptId, workerId: worker.id });295    return { ok: true as const, lastSeq: last };296  });297}298299export async function reportStage(300  db: Db,301  worker: WorkerRow,302  runId: string,303  report: {304    fencingToken: number;305    stage: StageKey;306    status: "running" | "completed" | "failed" | "skipped";307    costUsd: number;308    turns: number;309    usage?: Record<string, unknown>;310    error?: string;311    checkpoint?: Record<string, unknown>;312  },313) {314  return db.transaction(async (tx) => {315    const lease = await assertLease(tx, runId, worker, report.fencingToken);316    const [run] = await tx.select().from(researchRuns).where(eq(researchRuns.id, runId)).for("update");317    if (!run) throw notFound("run");318    if (report.status === "running") {319      await tx320        .insert(stageExecutions)321        .values({ runId, attemptId: lease.attemptId, stage: report.stage, status: "running" })322        .onConflictDoUpdate({323          target: [stageExecutions.attemptId, stageExecutions.stage],324          set: { status: "running", startedAt: new Date(), endedAt: null, error: null },325        });326      await tx327        .update(researchRuns)328        .set({ currentStage: report.stage, updatedAt: new Date() })329        .where(eq(researchRuns.id, runId));330      if (run.status === "provisioning")331        await transition(tx, runId, "running", {332          actor: `worker:${worker.name}`,333          attemptId: lease.attemptId,334          workerId: worker.id,335        });336      await tx.update(runAttempts).set({ status: "running" }).where(eq(runAttempts.id, lease.attemptId));337    } else {338      await tx339        .insert(stageExecutions)340        .values({341          runId,342          attemptId: lease.attemptId,343          stage: report.stage,344          status: report.status,345          endedAt: new Date(),346          costUsd: String(report.costUsd),347          turns: report.turns,348          usage: report.usage ?? null,349          error: report.error ?? null,350        })351        .onConflictDoUpdate({352          target: [stageExecutions.attemptId, stageExecutions.stage],353          set: {354            status: report.status,355            endedAt: new Date(),356            costUsd: String(report.costUsd),357            turns: report.turns,358            usage: report.usage ?? null,359            error: report.error ?? null,360          },361        });362      if (report.costUsd > 0) {363        const u = (report.usage ?? {}) as Record<string, number | string>;364        await tx.insert(providerUsage).values({365          runId,366          attemptId: lease.attemptId,367          stage: report.stage,368          model: String(u.model ?? run.model),369          inputTokens: Number(u.inputTokens ?? 0),370          outputTokens: Number(u.outputTokens ?? 0),371          cacheReadTokens: Number(u.cacheReadTokens ?? 0),372          cacheCreateTokens: Number(u.cacheCreateTokens ?? 0),373          costUsd: String(report.costUsd),374          turns: report.turns,375        });376        await tx377          .update(researchRuns)378          .set({ totalCostUsd: sql`${researchRuns.totalCostUsd} + ${String(report.costUsd)}` })379          .where(eq(researchRuns.id, runId));380        await tx381          .update(runAttempts)382          .set({ costUsd: sql`${runAttempts.costUsd} + ${String(report.costUsd)}` })383          .where(eq(runAttempts.id, lease.attemptId));384      }385      if (report.status === "completed" && report.checkpoint) {386        await tx387          .insert(checkpoints)388          .values({ runId, attemptId: lease.attemptId, stage: report.stage, data: report.checkpoint });389      }390    }391    await appendEvent(tx, runId, {392      kind: `stage.${report.status}`,393      level: report.status === "failed" ? "error" : "info",394      stage: report.stage,395      message: `${STAGES.find((s) => s.key === report.stage)?.name ?? report.stage} ${report.status}${report.error ? `: ${report.error}` : ""}`,396      data: { costUsd: report.costUsd, turns: report.turns },397      at: new Date().toISOString(),398      attemptId: lease.attemptId,399      workerId: worker.id,400    });401    return { ok: true as const };402  });403}404405export async function ingestArtifact(406  db: Db,407  storage: FilesystemStorage,408  worker: WorkerRow,409  runId: string,410  fencingToken: number,411  artifact: ArtifactDescriptor,412  body: Buffer,413) {414  const desc = artifact;415  if (body.length !== desc.bytes) throw invalid(`byte length mismatch: ${body.length} != ${desc.bytes}`);416  if (body.length > 512 * 1024 * 1024) throw invalid("artifact exceeds 512 MiB");417  const lease = await assertLease(db, runId, worker, fencingToken);418  const [attempt] = await db.select().from(runAttempts).where(eq(runAttempts.id, lease.attemptId)).limit(1);419  // existing versions of this path (any attempt): identical content is idempotent, changed content gets a new version420  const [prev] = await db421    .select({ version: artifacts.version, sha256: artifacts.sha256, id: artifacts.id })422    .from(artifacts)423    .where(and(eq(artifacts.runId, runId), eq(artifacts.path, desc.path)))424    .orderBy(descOrder(artifacts.version))425    .limit(1);426  if (prev && prev.sha256 === desc.sha256)427    return { ok: true as const, artifactId: prev.id, sha256: prev.sha256, bytes: body.length };428  const version = (prev?.version ?? 0) + 1;429  const key = runArtifactKey(430    runId,431    attempt!.attemptNo,432    version === 1 ? desc.path : `${desc.path}.v${version}`,433  );434  const stored = await storage.put(key, body, desc.sha256);435  return db.transaction(async (tx) => {436    await assertLease(tx, runId, worker, fencingToken);437    const [row] = await tx438      .insert(artifacts)439      .values({440        runId,441        attemptId: lease.attemptId,442        stage: desc.stage,443        path: desc.path,444        version,445        kind: desc.kind,446        sha256: stored.sha256,447        bytes: stored.bytes,448        contentType: desc.contentType,449        storageKey: key,450        synthetic: desc.synthetic,451      })452      .onConflictDoNothing()453      .returning({ id: artifacts.id });454    if (!row) {455      const [again] = await tx456        .select({ id: artifacts.id })457        .from(artifacts)458        .where(and(eq(artifacts.runId, runId), eq(artifacts.path, desc.path), eq(artifacts.version, version)))459        .limit(1);460      return { ok: true as const, artifactId: again!.id, sha256: stored.sha256, bytes: stored.bytes };461    }462    return { ok: true as const, artifactId: row.id, sha256: stored.sha256, bytes: stored.bytes };463  });464}465466export async function completeAttempt(467  db: Db,468  worker: WorkerRow,469  runId: string,470  body: {471    fencingToken: number;472    outcome: "completed" | "failed" | "cancelled" | "budget_exceeded" | "timeout";473    error?: string;474    summary?: Record<string, unknown>;475    costUsd: number;476    resourceUsage?: { cpuSeconds?: number; wallSeconds?: number; diskBytes?: number; peakRssBytes?: number };477  },478) {479  return db.transaction(async (tx) => {480    const lease = await assertLease(tx, runId, worker, body.fencingToken);481    const [run] = await tx.select().from(researchRuns).where(eq(researchRuns.id, runId)).for("update");482    if (!run) throw notFound("run");483    await tx484      .update(runAttempts)485      .set({486        status: body.outcome,487        endedAt: new Date(),488        error: body.error ?? null,489        summary: body.summary ?? null,490      })491      .where(eq(runAttempts.id, lease.attemptId));492    if (body.resourceUsage)493      await tx.insert(resourceUsage).values({494        runId,495        attemptId: lease.attemptId,496        workerId: worker.id,497        cpuSeconds: body.resourceUsage.cpuSeconds?.toFixed(2),498        wallSeconds: body.resourceUsage.wallSeconds?.toFixed(2),499        diskBytes: body.resourceUsage.diskBytes,500        peakRssBytes: body.resourceUsage.peakRssBytes,501      });502    await tx.delete(jobLeases).where(eq(jobLeases.runId, runId));503    await tx504      .update(workerAssignments)505      .set({ releasedAt: new Date(), releaseReason: body.outcome })506      .where(eq(workerAssignments.attemptId, lease.attemptId));507    await tx508      .update(computeWorkers)509      .set({ running: sql`greatest(${computeWorkers.running} - 1, 0)` })510      .where(eq(computeWorkers.id, worker.id));511    const base = { actor: `worker:${worker.name}`, attemptId: lease.attemptId, workerId: worker.id };512    if (body.outcome === "completed") {513      await transition(tx, runId, "quality_check", {514        ...base,515        reason: "worker reported completion; awaiting deterministic quality evaluation",516      });517    } else if (body.outcome === "cancelled" || run.status === "cancelling") {518      await transition(tx, runId, "cancelled", { ...base, reason: body.error ?? "cancelled" });519    } else {520      const reason = `${body.outcome}${body.error ? `: ${body.error}` : ""}`;521      const noRetry = /^(INFEASIBLE|POLICY)\b/.test(body.error ?? "") || /INFEASIBLE:/.test(body.error ?? "");522      if (body.outcome === "budget_exceeded" || noRetry || run.currentAttemptNo >= run.maxAttempts) {523        await transition(tx, runId, "failed", { ...base, reason });524      } else {525        await transition(tx, runId, "retrying", {526          ...base,527          reason: `${reason}; attempt ${run.currentAttemptNo}/${run.maxAttempts}`,528        });529      }530    }531    return { ok: true as const };532  });533}534535/** Scheduler: expire stale leases and recover runs (retry or fail). Returns the run IDs touched. */536export async function expireStaleLeases(db: Db): Promise<string[]> {537  const stale = await db.select().from(jobLeases).where(lt(jobLeases.expiresAt, new Date()));538  const touched: string[] = [];539  for (const lease of stale) {540    await db.transaction(async (tx) => {541      const [run] = await tx542        .select()543        .from(researchRuns)544        .where(eq(researchRuns.id, lease.runId))545        .for("update");546      if (!run) return;547      const [still] = await tx548        .select()549        .from(jobLeases)550        .where(and(eq(jobLeases.runId, lease.runId), eq(jobLeases.fencingToken, lease.fencingToken)))551        .limit(1);552      if (!still || still.expiresAt.getTime() > Date.now()) return;553      await tx.delete(jobLeases).where(eq(jobLeases.runId, lease.runId));554      await tx555        .update(runAttempts)556        .set({ status: "expired", endedAt: new Date(), error: "lease expired (worker heartbeat lost)" })557        .where(eq(runAttempts.id, lease.attemptId));558      await tx559        .update(workerAssignments)560        .set({ releasedAt: new Date(), releaseReason: "lease_expired" })561        .where(eq(workerAssignments.attemptId, lease.attemptId));562      await tx563        .update(computeWorkers)564        .set({ running: sql`greatest(${computeWorkers.running} - 1, 0)` })565        .where(eq(computeWorkers.id, lease.workerId));566      const base = { actor: "scheduler", attemptId: lease.attemptId, workerId: lease.workerId };567      if (["provisioning", "running", "cancelling"].includes(run.status)) {568        if (run.status === "cancelling")569          await transition(tx, run.id, "cancelled", { ...base, reason: "lease expired during cancellation" });570        else if (run.currentAttemptNo >= run.maxAttempts)571          await transition(tx, run.id, "failed", {572            ...base,573            reason: "lease expired and attempt limit reached",574          });575        else576          await transition(tx, run.id, "retrying", {577            ...base,578            reason: `lease expired (attempt ${run.currentAttemptNo}/${run.maxAttempts}); will resume from last checkpoint`,579          });580      }581      touched.push(run.id);582    });583  }584  return touched;585}586587/** Scheduler: retrying → queued (immediate re-queue; backoff is applied through queuedAt ordering). */588export async function requeueRetrying(db: Db): Promise<number> {589  const rows = await db590    .select({ id: researchRuns.id })591    .from(researchRuns)592    .where(eq(researchRuns.status, "retrying"));593  for (const r of rows) await db.transaction((tx) => transition(tx, r.id, "queued", { actor: "scheduler" }));594  return rows.length;595}596597export async function markOfflineWorkers(db: Db, staleAfterMs = 90_000): Promise<number> {598  const cutoff = new Date(Date.now() - staleAfterMs);599  const rows = await db600    .update(computeWorkers)601    .set({ status: "offline" })602    .where(603      and(inArray(computeWorkers.status, ["online", "draining"]), lt(computeWorkers.lastHeartbeatAt, cutoff)),604    )605    .returning({ id: computeWorkers.id });606  return rows.length;607}608609export async function listWorkers(db: Db) {610  const rows = await db.select().from(computeWorkers).orderBy(asc(computeWorkers.name));611  return rows.map((w) => ({ ...w, tokenHash: undefined }));612}613614export async function setWorkerDrain(db: Db, workerId: string, drain: boolean, actor: { id: string }) {615  const [w] = await db616    .update(computeWorkers)617    .set({ drainRequested: drain ? new Date() : null, status: drain ? "draining" : "online" })618    .where(eq(computeWorkers.id, workerId))619    .returning();620  if (!w) throw notFound("worker");621  await audit(db, { type: "admin", id: actor.id }, drain ? "worker.drain" : "worker.undrain", {622    type: "worker",623    id: workerId,624  });625  return w;626}627628export async function setWorkerQuarantine(629  db: Db,630  workerId: string,631  reason: string | null,632  actor: { id: string },633) {634  const [w] = await db635    .update(computeWorkers)636    .set({ status: reason ? "quarantined" : "online", quarantineReason: reason })637    .where(eq(computeWorkers.id, workerId))638    .returning();639  if (!w) throw notFound("worker");640  await audit(641    db,642    { type: "admin", id: actor.id },643    reason ? "worker.quarantine" : "worker.unquarantine",644    { type: "worker", id: workerId },645    { reason },646  );647  return w;648}649650export async function workerTelemetry(db: Db, workerId: string, limit = 60) {651  return db652    .select()653    .from(workerHeartbeats)654    .where(eq(workerHeartbeats.workerId, workerId))655    .orderBy(desc(workerHeartbeats.createdAt))656    .limit(limit);657}658659export { conflict, isNull };660661/** Latest version of every artifact of a run, for a worker that holds the lease (resume on another node). */662export async function listRunArtifactsForWorker(663  db: Db,664  worker: WorkerRow,665  runId: string,666  fencingToken: number,667) {668  await assertLease(db, runId, worker, fencingToken);669  const rows = await db670    .select({671      path: artifacts.path,672      version: artifacts.version,673      sha256: artifacts.sha256,674      bytes: artifacts.bytes,675      contentType: artifacts.contentType,676      storageKey: artifacts.storageKey,677      kind: artifacts.kind,678    })679    .from(artifacts)680    .where(eq(artifacts.runId, runId))681    .orderBy(descOrder(artifacts.version));682  const latest = new Map<string, (typeof rows)[number]>();683  for (const r of rows) if (!latest.has(r.path)) latest.set(r.path, r);684  return [...latest.values()].map(({ storageKey: _k, ...rest }) => rest);685}686687export async function readRunArtifactForWorker(688  db: Db,689  storage: FilesystemStorage,690  worker: WorkerRow,691  runId: string,692  fencingToken: number,693  path: string,694) {695  await assertLease(db, runId, worker, fencingToken);696  const [row] = await db697    .select()698    .from(artifacts)699    .where(and(eq(artifacts.runId, runId), eq(artifacts.path, path)))700    .orderBy(descOrder(artifacts.version))701    .limit(1);702  if (!row) throw notFound("artifact");703  return { row, buf: await storage.get(row.storageKey) };704}705
Files · main
Menu

Theme