931 lines
29 KiB
TypeScript
931 lines
29 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
link,
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
readdir,
|
|
readFile,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
} from "node:fs/promises";
|
|
import * as path from "node:path";
|
|
import {
|
|
DEFAULT_WORKFLOW_CONFIG,
|
|
type PlanArtifact,
|
|
type WorkflowReportArtifact,
|
|
type WorkflowRun,
|
|
} from "./domain.ts";
|
|
import { deriveRunLabel } from "./run-label.ts";
|
|
|
|
const SNAPSHOT_VERSION = 1;
|
|
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
|
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
const MAX_PROTOTYPE_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
|
|
async function readVerifiedDocument(
|
|
artifact: { path: string; digest: string },
|
|
label: string,
|
|
maxBytes = MAX_DOCUMENT_BYTES,
|
|
) {
|
|
const details = await stat(artifact.path);
|
|
if (!details.isFile() || details.size > maxBytes) {
|
|
throw new Error(`${label} artifact exceeds the verified document limit`);
|
|
}
|
|
const contents = await readFile(artifact.path, "utf8");
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
if (digest !== artifact.digest) {
|
|
throw new Error(`${label} artifact digest mismatch: ${artifact.path}`);
|
|
}
|
|
return contents;
|
|
}
|
|
|
|
export function readVerifiedPlanArtifact(artifact: PlanArtifact) {
|
|
return readVerifiedDocument(artifact, "Plan");
|
|
}
|
|
|
|
export function readVerifiedReportArtifact(artifact: WorkflowReportArtifact) {
|
|
return readVerifiedDocument(artifact, "Report");
|
|
}
|
|
|
|
export function readVerifiedTranscriptArtifact(artifact: {
|
|
path: string;
|
|
digest: string;
|
|
}) {
|
|
return readVerifiedDocument(artifact, "Transcript", MAX_JSON_BYTES);
|
|
}
|
|
|
|
interface StoredSnapshot {
|
|
schemaVersion: 0 | typeof SNAPSHOT_VERSION;
|
|
run: WorkflowRun;
|
|
}
|
|
|
|
export interface AssignmentLeaseRecord {
|
|
token: string;
|
|
processId: number;
|
|
heartbeatAt: number;
|
|
}
|
|
|
|
export interface StartRecord {
|
|
key: string;
|
|
fingerprint: string;
|
|
runId: string;
|
|
status: "pending" | "completed";
|
|
result?: unknown;
|
|
}
|
|
|
|
export interface WorkflowStoreOptions {
|
|
beforeRename?: (target: string) => void | Promise<void>;
|
|
afterRunLoadMiss?: (runId: string) => void | Promise<void>;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function json(value: unknown, label: string) {
|
|
const contents = `${JSON.stringify(value, null, 2)}\n`;
|
|
if (Buffer.byteLength(contents) > MAX_JSON_BYTES) {
|
|
throw new Error(`${label} exceeds ${MAX_JSON_BYTES} bytes`);
|
|
}
|
|
return contents;
|
|
}
|
|
|
|
async function syncDirectory(directory: string) {
|
|
let handle;
|
|
try {
|
|
handle = await open(directory, "r");
|
|
await handle.sync();
|
|
} catch {
|
|
// Some filesystems do not support directory fsync.
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
}
|
|
|
|
export class WorkflowStore {
|
|
readonly root: string;
|
|
private readonly options: WorkflowStoreOptions;
|
|
|
|
constructor(root: string, options: WorkflowStoreOptions = {}) {
|
|
this.root = root;
|
|
this.options = options;
|
|
}
|
|
|
|
async initialize() {
|
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
await mkdir(this.runsRoot(), { recursive: true, mode: 0o700 });
|
|
await mkdir(this.startsRoot(), { recursive: true, mode: 0o700 });
|
|
}
|
|
|
|
async createRun(run: WorkflowRun) {
|
|
await this.initialize();
|
|
const directory = this.runDirectory(run.runId);
|
|
try {
|
|
return { run: await this.loadRun(run.runId), created: false as const };
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
await this.options.afterRunLoadMiss?.(run.runId);
|
|
|
|
const existing = await stat(directory).catch(() => undefined);
|
|
if (existing) {
|
|
try {
|
|
return { run: await this.loadRun(run.runId), created: false as const };
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
throw new Error(
|
|
`Workflow Run ${run.runId} has an incomplete published directory; inspect and remove it before retrying`,
|
|
);
|
|
}
|
|
const staging = path.join(
|
|
this.runsRoot(),
|
|
`.${run.runId}.${process.pid}.${randomUUID()}.creating`,
|
|
);
|
|
try {
|
|
await mkdir(staging, { mode: 0o700 });
|
|
await this.writeInitialRun(staging, run);
|
|
await rename(staging, directory);
|
|
await syncDirectory(this.runsRoot());
|
|
return { run, created: true as const };
|
|
} catch (error) {
|
|
await rm(staging, { recursive: true, force: true });
|
|
if (
|
|
(error as NodeJS.ErrnoException).code === "EEXIST" ||
|
|
(error as NodeJS.ErrnoException).code === "ENOTEMPTY"
|
|
) {
|
|
return {
|
|
run: await this.loadRun(run.runId),
|
|
created: false as const,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async saveRun(run: WorkflowRun, expectedRevision?: number) {
|
|
const current = await this.loadRun(run.runId);
|
|
const expected = expectedRevision ?? current.revision;
|
|
if (current.revision !== expected || run.revision !== expected + 1) {
|
|
throw new Error(
|
|
`Stale workflow revision ${expected}; current revision is ${current.revision}`,
|
|
);
|
|
}
|
|
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
|
|
const contents = json(snapshot, `workflow ${run.runId}`);
|
|
try {
|
|
await this.atomicCreate(
|
|
this.revisionPath(run.runId, run.revision),
|
|
contents,
|
|
);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
const latest = await this.loadRun(run.runId);
|
|
throw new Error(
|
|
`Stale workflow revision ${expected}; current revision is ${latest.revision}`,
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
await this.atomicWrite(
|
|
path.join(this.runDirectory(run.runId), "workflow.json"),
|
|
contents,
|
|
);
|
|
}
|
|
|
|
async createAssignmentLease(
|
|
runId: string,
|
|
assignmentId: string,
|
|
lease: AssignmentLeaseRecord,
|
|
) {
|
|
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
|
if (existing && existing.token !== lease.token) {
|
|
throw new Error(`Assignment ${assignmentId} already has an active lease`);
|
|
}
|
|
await this.atomicWrite(
|
|
this.assignmentLeasePath(runId, assignmentId),
|
|
json(lease, `assignment lease ${assignmentId}`),
|
|
);
|
|
return lease;
|
|
}
|
|
|
|
async heartbeatAssignmentLease(
|
|
runId: string,
|
|
assignmentId: string,
|
|
token: string,
|
|
heartbeatAt: number,
|
|
) {
|
|
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
|
if (!existing || existing.token !== token) return false;
|
|
await this.atomicWrite(
|
|
this.assignmentLeasePath(runId, assignmentId),
|
|
json({ ...existing, heartbeatAt }, `assignment lease ${assignmentId}`),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
async loadAssignmentLease(
|
|
runId: string,
|
|
assignmentId: string,
|
|
): Promise<AssignmentLeaseRecord | undefined> {
|
|
try {
|
|
const parsed = await readJson(
|
|
this.assignmentLeasePath(runId, assignmentId),
|
|
);
|
|
if (
|
|
!isRecord(parsed) ||
|
|
typeof parsed.token !== "string" ||
|
|
typeof parsed.processId !== "number" ||
|
|
typeof parsed.heartbeatAt !== "number"
|
|
) {
|
|
throw new Error(`Assignment ${assignmentId} has an invalid lease`);
|
|
}
|
|
return {
|
|
token: parsed.token,
|
|
processId: parsed.processId,
|
|
heartbeatAt: parsed.heartbeatAt,
|
|
};
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async releaseAssignmentLease(
|
|
runId: string,
|
|
assignmentId: string,
|
|
token: string,
|
|
) {
|
|
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
|
if (!existing || existing.token !== token) return false;
|
|
await rm(this.assignmentLeasePath(runId, assignmentId), { force: true });
|
|
return true;
|
|
}
|
|
|
|
async loadRun(runId: string): Promise<WorkflowRun> {
|
|
const revisions = path.join(this.runDirectory(runId), "revisions");
|
|
const entries = await readdir(revisions);
|
|
const latest = entries
|
|
.map((entry) => /^(\d+)\.json$/.exec(entry))
|
|
.filter((match): match is RegExpExecArray => match !== null)
|
|
.map((match) => Number(match[1]))
|
|
.sort((left, right) => right - left)[0];
|
|
if (latest === undefined) {
|
|
throw new Error(`${revisions}: no workflow revisions`);
|
|
}
|
|
const file = this.revisionPath(runId, latest);
|
|
const parsed = await readJson(file);
|
|
if (
|
|
!isRecord(parsed) ||
|
|
(parsed.schemaVersion !== 0 &&
|
|
parsed.schemaVersion !== SNAPSHOT_VERSION) ||
|
|
!isRecord(parsed.run) ||
|
|
parsed.run.runId !== runId
|
|
) {
|
|
throw new Error(`${file}: unsupported or invalid workflow snapshot`);
|
|
}
|
|
// SAFETY: saveRun is the sole writer; the wrapper version and run ID were validated above.
|
|
const run = parsed.run as unknown as WorkflowRun;
|
|
run.background ??= false;
|
|
run.assignments ??= [];
|
|
run.deliveries ??= [];
|
|
run.prototypeCandidates ??= [];
|
|
run.commandReceipts ??= [];
|
|
run.mutatorProfileNames ??= [];
|
|
run.prototypeCleanupCompleted ??= false;
|
|
run.uiImpactEvidence ??= [];
|
|
run.affectedUiSurfaces ??= [];
|
|
run.reviewCycles ??= 0;
|
|
run.verificationCycles ??= 0;
|
|
run.effectiveConfig.standardAgents ??= structuredClone(
|
|
DEFAULT_WORKFLOW_CONFIG.standardAgents,
|
|
);
|
|
for (const assignment of run.assignments) {
|
|
if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {};
|
|
if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {};
|
|
}
|
|
for (const delivery of run.deliveries) {
|
|
try {
|
|
const receipt = await readJson(
|
|
this.deliveryReceiptPath(run.runId, delivery.key),
|
|
);
|
|
if (isRecord(receipt) && typeof receipt.sentAt === "number") {
|
|
delivery.state = "sent";
|
|
delivery.sentAt = receipt.sentAt;
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
return run;
|
|
}
|
|
|
|
async listRuns() {
|
|
await this.initialize();
|
|
const entries = await readdir(this.runsRoot(), { withFileTypes: true });
|
|
const runs: WorkflowRun[] = [];
|
|
for (const entry of entries.sort((left, right) =>
|
|
left.name === right.name ? 0 : left.name < right.name ? -1 : 1,
|
|
)) {
|
|
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
const snapshot = path.join(this.runsRoot(), entry.name, "workflow.json");
|
|
try {
|
|
await stat(snapshot);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
throw error;
|
|
}
|
|
runs.push(await this.loadRun(entry.name));
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
async saveReport(run: WorkflowRun) {
|
|
const lines = [
|
|
`# pi-workflow ${deriveRunLabel(run)}`,
|
|
"",
|
|
`- Run ID: ${run.runId}`,
|
|
`- Status: ${run.status}`,
|
|
`- Revision: ${run.revision}`,
|
|
`- Mode: ${run.mode}`,
|
|
`- Request: ${reportText(run.request, 8_000)}`,
|
|
`- Worktree: ${run.workspace.root ?? "not required"}`,
|
|
`- Branch: ${run.workspace.branch ?? "not required"}`,
|
|
`- Plan: ${run.planArtifact?.path ?? "none"}`,
|
|
"",
|
|
"## Gates",
|
|
...boundedReportEntries(
|
|
run.gates,
|
|
(gate) =>
|
|
`- ${gate.kind}: ${gate.status} (${gate.id}, revision ${gate.revision})`,
|
|
),
|
|
"",
|
|
"## Decisions",
|
|
...boundedReportEntries(
|
|
run.decisions,
|
|
(decision) =>
|
|
`- ${decision.gateId} revision ${decision.revision}: ${reportText(decision.value)}`,
|
|
),
|
|
"",
|
|
"## Assignments",
|
|
...boundedReportEntries(
|
|
run.assignments,
|
|
(assignment) =>
|
|
`- ${assignment.role}/${assignment.profileName}: ${assignment.status} (${assignment.id})`,
|
|
1_024,
|
|
),
|
|
"",
|
|
"## Prototype Evidence",
|
|
...boundedReportEntries(
|
|
run.prototypeCandidates,
|
|
(candidate) =>
|
|
`- ${candidate.id}: ${reportText(candidate.route, 500)} | ${candidate.artifactPath}`,
|
|
),
|
|
"",
|
|
"## Verification",
|
|
...(run.verificationEvidence
|
|
? boundedReportEntries(
|
|
run.verificationEvidence.commands,
|
|
(command) =>
|
|
`- ${command.kind}: ${command.status} | ${reportText(`${command.executable} ${command.args.join(" ")}`, 2_000)}`,
|
|
1_024,
|
|
)
|
|
: ["- none"]),
|
|
"",
|
|
"## Review",
|
|
"```json",
|
|
reportText(run.reviewVerdict ?? null, 64 * 1024),
|
|
"```",
|
|
...(run.error
|
|
? [
|
|
"",
|
|
"## Error",
|
|
`${run.error.code}: ${reportText(run.error.message, 8_000)}`,
|
|
]
|
|
: []),
|
|
];
|
|
const contents = boundedReportDocument(lines.join("\n"));
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const reportPath = path.join(
|
|
this.runDirectory(run.runId),
|
|
"artifacts",
|
|
`report-${run.revision}-${digest}.md`,
|
|
);
|
|
await this.atomicWrite(reportPath, contents);
|
|
return { path: reportPath, digest, revision: run.revision };
|
|
}
|
|
|
|
async ensureResult(run: WorkflowRun) {
|
|
try {
|
|
await stat(path.join(this.runDirectory(run.runId), "result.json"));
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
await this.publishResult(run);
|
|
}
|
|
|
|
async publishResult(run: WorkflowRun) {
|
|
await this.atomicWrite(
|
|
path.join(this.runDirectory(run.runId), "result.json"),
|
|
json(
|
|
{
|
|
runId: run.runId,
|
|
revision: run.reportArtifact?.revision ?? run.revision,
|
|
snapshotRevision: run.revision,
|
|
status: run.status,
|
|
reportPath: run.reportArtifact?.path,
|
|
planPath: run.planArtifact?.path,
|
|
worktreePath: run.workspace.root,
|
|
reviewVerdict: run.reviewVerdict,
|
|
error: run.error,
|
|
},
|
|
"workflow result",
|
|
),
|
|
);
|
|
}
|
|
|
|
async savePlan(runId: string, revision: number, contents: string) {
|
|
const bounded = boundedDocument(contents, "plan");
|
|
const digest = createHash("sha256").update(bounded).digest("hex");
|
|
const directory = this.runDirectory(runId);
|
|
const file = path.join(directory, "plan.md");
|
|
const versionedFile = path.join(
|
|
directory,
|
|
"artifacts",
|
|
`plan-${revision}-${digest}.md`,
|
|
);
|
|
await this.atomicWrite(versionedFile, bounded);
|
|
await this.atomicWrite(file, bounded);
|
|
const artifact: PlanArtifact = {
|
|
path: versionedFile,
|
|
revision,
|
|
digest,
|
|
};
|
|
return artifact;
|
|
}
|
|
|
|
async savePrototypeArtifact(
|
|
runId: string,
|
|
candidateId: string,
|
|
evidence: unknown,
|
|
sourceRoot: string,
|
|
artifactFiles: string[],
|
|
) {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(candidateId)) {
|
|
throw new Error("invalid prototype candidate ID");
|
|
}
|
|
const root = await realpath(sourceRoot);
|
|
const artifactRoot = path.join(
|
|
this.runDirectory(runId),
|
|
"artifacts",
|
|
"prototypes",
|
|
candidateId,
|
|
);
|
|
let totalBytes = 0;
|
|
const retainedFiles: string[] = [];
|
|
for (const relativePath of [...new Set(artifactFiles)]) {
|
|
if (!relativePath || path.isAbsolute(relativePath)) {
|
|
throw new Error("prototype artifact path must be relative");
|
|
}
|
|
const target = await realpath(path.resolve(root, relativePath));
|
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
|
throw new Error("prototype artifact path escapes source root");
|
|
}
|
|
const details = await lstat(target);
|
|
if (details.isSymbolicLink() || !details.isFile()) {
|
|
throw new Error("prototype artifacts must be regular files");
|
|
}
|
|
const contents = await readFile(target);
|
|
totalBytes += contents.byteLength;
|
|
if (totalBytes > MAX_PROTOTYPE_ARTIFACT_BYTES) {
|
|
throw new Error("prototype artifacts exceed the 8 MiB retained limit");
|
|
}
|
|
const digest = createHash("sha256").update(contents).digest("hex");
|
|
const destination = path.join(
|
|
artifactRoot,
|
|
`${digest}-${path.basename(relativePath)}`,
|
|
);
|
|
await this.atomicWrite(destination, contents);
|
|
retainedFiles.push(destination);
|
|
}
|
|
const file = path.join(artifactRoot, "candidate.json");
|
|
const contents = json(
|
|
{ candidate: evidence, retainedFiles },
|
|
`prototype candidate ${candidateId}`,
|
|
);
|
|
await this.atomicWrite(file, contents);
|
|
return { path: file, retainedFiles };
|
|
}
|
|
|
|
async saveAssignmentTranscript(
|
|
runId: string,
|
|
assignmentId: string,
|
|
transcript: unknown[],
|
|
) {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
|
|
throw new Error("invalid assignment ID");
|
|
}
|
|
const contents = json(transcript, `assignment transcript ${assignmentId}`);
|
|
const file = path.join(
|
|
this.runDirectory(runId),
|
|
"artifacts",
|
|
"transcripts",
|
|
`${assignmentId}.json`,
|
|
);
|
|
await this.atomicWrite(file, contents);
|
|
return {
|
|
path: file,
|
|
digest: createHash("sha256").update(contents).digest("hex"),
|
|
};
|
|
}
|
|
|
|
async claimDelivery(
|
|
runId: string,
|
|
key: string,
|
|
token: string,
|
|
claimedAt: number,
|
|
) {
|
|
const claimPath = this.deliveryClaimPath(runId, key);
|
|
await mkdir(path.dirname(claimPath), { recursive: true, mode: 0o700 });
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
let handle;
|
|
try {
|
|
handle = await open(claimPath, "wx", 0o600);
|
|
await handle.writeFile(
|
|
`${JSON.stringify({ key, token, claimedAt })}\n`,
|
|
"utf8",
|
|
);
|
|
await handle.sync();
|
|
await handle.close();
|
|
await syncDirectory(path.dirname(claimPath));
|
|
return true;
|
|
} catch (error) {
|
|
await handle?.close();
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
}
|
|
const existing = await readJson(claimPath).catch(() => undefined);
|
|
if (
|
|
!isRecord(existing) ||
|
|
typeof existing.claimedAt !== "number" ||
|
|
claimedAt - existing.claimedAt < 30_000
|
|
) {
|
|
return false;
|
|
}
|
|
const stalePath = `${claimPath}.${randomUUID()}.stale`;
|
|
try {
|
|
await rename(claimPath, stalePath);
|
|
await rm(stalePath, { force: true });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async clearDeliveryClaim(runId: string, key: string) {
|
|
await rm(this.deliveryClaimPath(runId, key), { force: true });
|
|
}
|
|
|
|
async releaseDeliveryClaim(runId: string, key: string, token: string) {
|
|
const claimPath = this.deliveryClaimPath(runId, key);
|
|
const existing = await readJson(claimPath).catch(() => undefined);
|
|
if (!isRecord(existing) || existing.token !== token) return;
|
|
const releasedPath = `${claimPath}.${randomUUID()}.released`;
|
|
try {
|
|
await rename(claimPath, releasedPath);
|
|
await rm(releasedPath, { force: true });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
async markDeliverySent(runId: string, key: string, sentAt: number) {
|
|
const run = await this.loadRun(runId);
|
|
const delivery = run.deliveries.find((candidate) => candidate.key === key);
|
|
if (!delivery || delivery.state === "sent") return run;
|
|
await this.atomicWrite(
|
|
this.deliveryReceiptPath(runId, key),
|
|
`${JSON.stringify({ key, sentAt })}\n`,
|
|
);
|
|
delivery.state = "sent";
|
|
delivery.sentAt = sentAt;
|
|
return run;
|
|
}
|
|
|
|
async getStart(key: string) {
|
|
const directory = this.startDirectory(key);
|
|
try {
|
|
await stat(path.join(directory, "record.json"));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
return this.readStartRecord(directory);
|
|
}
|
|
|
|
async reserveStart(key: string, fingerprint: string, runId: string) {
|
|
await this.initialize();
|
|
const directory = this.startDirectory(key);
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
let created = false;
|
|
let record: StartRecord;
|
|
try {
|
|
record = await this.readStartRecord(directory);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
const candidate: StartRecord = {
|
|
key,
|
|
fingerprint,
|
|
runId,
|
|
status: "pending",
|
|
};
|
|
try {
|
|
await this.atomicCreate(
|
|
path.join(directory, "record.json"),
|
|
json(candidate, "start record"),
|
|
);
|
|
record = candidate;
|
|
created = true;
|
|
} catch (createError) {
|
|
if ((createError as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
throw createError;
|
|
}
|
|
record = await this.readStartRecord(directory);
|
|
}
|
|
}
|
|
if (record.key !== key || record.fingerprint !== fingerprint) {
|
|
throw new Error(
|
|
"idempotency key was already used with another start command",
|
|
);
|
|
}
|
|
return { created, claimed: true as const, record };
|
|
}
|
|
|
|
async completeStart(key: string, result: unknown) {
|
|
const directory = this.startDirectory(key);
|
|
const record = await this.readStartRecord(directory);
|
|
if (
|
|
!isRecord(result) ||
|
|
result.runId !== record.runId ||
|
|
!Number.isInteger(result.revision) ||
|
|
(result.revision as number) < 0
|
|
) {
|
|
throw new Error("start result does not match its Workflow Run revision");
|
|
}
|
|
const target = path.join(
|
|
directory,
|
|
"results",
|
|
`${result.revision as number}.json`,
|
|
);
|
|
try {
|
|
await this.atomicCreate(target, json(result, "start result"));
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
}
|
|
return this.readStartRecord(directory);
|
|
}
|
|
|
|
async findRunByBranch(branch: string) {
|
|
const runs = await this.listRuns();
|
|
return runs.find((run) => run.workspace.branch === branch);
|
|
}
|
|
|
|
private async writeInitialRun(directory: string, run: WorkflowRun) {
|
|
await mkdir(path.join(directory, "profiles"), { mode: 0o700 });
|
|
await mkdir(path.join(directory, "handoffs"), { mode: 0o700 });
|
|
await mkdir(path.join(directory, "artifacts"), { mode: 0o700 });
|
|
await mkdir(path.join(directory, "assignment-leases"), { mode: 0o700 });
|
|
await mkdir(path.join(directory, "revisions"), { mode: 0o700 });
|
|
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
|
|
const contents = json(snapshot, `workflow ${run.runId}`);
|
|
await this.atomicWrite(
|
|
path.join(directory, "revisions", "0.json"),
|
|
contents,
|
|
);
|
|
await this.atomicWrite(path.join(directory, "workflow.json"), contents);
|
|
await this.atomicWrite(
|
|
path.join(directory, "request.md"),
|
|
boundedDocument(run.request, "request"),
|
|
);
|
|
await this.atomicWrite(
|
|
path.join(directory, "config.json"),
|
|
json(run.effectiveConfig, "effective configuration"),
|
|
);
|
|
for (const profile of run.pinnedProfiles) {
|
|
await this.atomicWrite(
|
|
path.join(directory, "profiles", `${profile.name}.json`),
|
|
json(profile, `profile ${profile.name}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
private runsRoot() {
|
|
return path.join(this.root, "runs");
|
|
}
|
|
|
|
private startsRoot() {
|
|
return path.join(this.root, "starts");
|
|
}
|
|
|
|
private runDirectory(runId: string) {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) throw new Error("invalid run ID");
|
|
return path.join(this.runsRoot(), runId);
|
|
}
|
|
|
|
private deliveryClaimPath(runId: string, key: string) {
|
|
const digest = createHash("sha256").update(key).digest("hex");
|
|
return path.join(
|
|
this.runDirectory(runId),
|
|
"delivery-claims",
|
|
`${digest}.json`,
|
|
);
|
|
}
|
|
|
|
private deliveryReceiptPath(runId: string, key: string) {
|
|
const digest = createHash("sha256").update(key).digest("hex");
|
|
return path.join(
|
|
this.runDirectory(runId),
|
|
"delivery-receipts",
|
|
`${digest}.json`,
|
|
);
|
|
}
|
|
|
|
private revisionPath(runId: string, revision: number) {
|
|
if (!Number.isInteger(revision) || revision < 0) {
|
|
throw new Error("invalid workflow revision");
|
|
}
|
|
return path.join(this.runDirectory(runId), "revisions", `${revision}.json`);
|
|
}
|
|
|
|
private assignmentLeasePath(runId: string, assignmentId: string) {
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
|
|
throw new Error("invalid assignment ID");
|
|
}
|
|
return path.join(
|
|
this.runDirectory(runId),
|
|
"assignment-leases",
|
|
`${assignmentId}.json`,
|
|
);
|
|
}
|
|
|
|
private startDigest(key: string) {
|
|
return createHash("sha256").update(key).digest("hex");
|
|
}
|
|
|
|
private startDirectory(key: string) {
|
|
return path.join(this.startsRoot(), this.startDigest(key));
|
|
}
|
|
|
|
private async readStartRecord(directory: string) {
|
|
const file = path.join(directory, "record.json");
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = await readJson(file);
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
if (
|
|
!isRecord(parsed) ||
|
|
typeof parsed.key !== "string" ||
|
|
typeof parsed.fingerprint !== "string" ||
|
|
typeof parsed.runId !== "string" ||
|
|
(parsed.status !== "pending" && parsed.status !== "completed")
|
|
) {
|
|
throw new Error(`${file}: invalid start record`);
|
|
}
|
|
// SAFETY: all persisted StartRecord identity and discriminator fields were validated above.
|
|
const intent = parsed as unknown as StartRecord;
|
|
const resultsDirectory = path.join(directory, "results");
|
|
let resultEntries: string[] = [];
|
|
try {
|
|
resultEntries = await readdir(resultsDirectory);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
const latest = resultEntries
|
|
.map((entry) => /^(\d+)\.json$/.exec(entry))
|
|
.filter((match): match is RegExpExecArray => match !== null)
|
|
.map((match) => Number(match[1]))
|
|
.sort((left, right) => right - left)[0];
|
|
if (latest === undefined) return { ...intent, status: "pending" as const };
|
|
const result = await readJson(
|
|
path.join(resultsDirectory, `${latest}.json`),
|
|
);
|
|
if (
|
|
!isRecord(result) ||
|
|
result.runId !== intent.runId ||
|
|
result.revision !== latest
|
|
) {
|
|
throw new Error(`${resultsDirectory}: invalid start result`);
|
|
}
|
|
return { ...intent, status: "completed" as const, result };
|
|
}
|
|
|
|
private async atomicCreate(target: string, contents: string) {
|
|
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
const temporary = path.join(
|
|
path.dirname(target),
|
|
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
|
|
);
|
|
let handle;
|
|
try {
|
|
handle = await open(temporary, "wx", 0o600);
|
|
await handle.writeFile(contents, "utf8");
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await this.options.beforeRename?.(target);
|
|
await link(temporary, target);
|
|
await rm(temporary, { force: true });
|
|
await syncDirectory(path.dirname(target));
|
|
} catch (error) {
|
|
await handle?.close();
|
|
await rm(temporary, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async atomicWrite(target: string, contents: string | Uint8Array) {
|
|
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
const temporary = path.join(
|
|
path.dirname(target),
|
|
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
|
|
);
|
|
let handle;
|
|
try {
|
|
handle = await open(temporary, "wx", 0o600);
|
|
if (typeof contents === "string") await handle.writeFile(contents, "utf8");
|
|
else await handle.writeFile(contents);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await this.options.beforeRename?.(target);
|
|
await rename(temporary, target);
|
|
await syncDirectory(path.dirname(target));
|
|
} catch (error) {
|
|
await handle?.close();
|
|
await rm(temporary, { force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
function reportText(value: unknown, maxCharacters = 2_000) {
|
|
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
const characters = [...text];
|
|
return characters.length <= maxCharacters
|
|
? text
|
|
: `${characters.slice(0, maxCharacters).join("")}\n[truncated]`;
|
|
}
|
|
|
|
function boundedReportEntries<T>(
|
|
entries: T[],
|
|
render: (entry: T) => string,
|
|
limit = 128,
|
|
) {
|
|
const included = entries.slice(-limit).map(render);
|
|
if (entries.length > limit) {
|
|
included.unshift(`- [${entries.length - limit} earlier entries omitted]`);
|
|
}
|
|
return included;
|
|
}
|
|
|
|
function boundedReportDocument(contents: string) {
|
|
const normalized = contents.endsWith("\n") ? contents : `${contents}\n`;
|
|
if (Buffer.byteLength(normalized, "utf8") <= MAX_DOCUMENT_BYTES) return normalized;
|
|
const marker = "\n[report truncated to 1 MiB]\n";
|
|
const limit = MAX_DOCUMENT_BYTES - Buffer.byteLength(marker, "utf8");
|
|
let bytes = 0;
|
|
const kept: string[] = [];
|
|
for (const character of normalized) {
|
|
const width = Buffer.byteLength(character, "utf8");
|
|
if (bytes + width > limit) break;
|
|
kept.push(character);
|
|
bytes += width;
|
|
}
|
|
return `${kept.join("")}${marker}`;
|
|
}
|
|
|
|
function boundedDocument(contents: string, label: string) {
|
|
if (Buffer.byteLength(contents) > MAX_DOCUMENT_BYTES) {
|
|
throw new Error(`${label} exceeds ${MAX_DOCUMENT_BYTES} bytes`);
|
|
}
|
|
return contents.endsWith("\n") ? contents : `${contents}\n`;
|
|
}
|
|
|
|
async function readJson(file: string) {
|
|
const info = await stat(file);
|
|
if (info.size > MAX_JSON_BYTES)
|
|
throw new Error(`${file}: JSON file is too large`);
|
|
const contents = await readFile(file, "utf8");
|
|
try {
|
|
return JSON.parse(contents) as unknown;
|
|
} catch (error) {
|
|
throw new Error(`${file}: invalid JSON`, { cause: error });
|
|
}
|
|
}
|