From 8be0d4d2ec0e094afbfd9f695808b7676a5012aa Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 1 Jun 2026 17:53:57 -0400 Subject: [PATCH] feat: add durable run registry --- package.json | 1 + packages/cli/src/command-router.ts | 14 ++ packages/cli/src/commands/recipe-run.ts | 60 ++++++- packages/cli/src/commands/runs.ts | 96 ++++++++++ packages/cli/src/index.ts | 3 + packages/cli/src/output.ts | 6 + packages/runtime-core/src/index.ts | 1 + packages/runtime-core/src/run-registry.ts | 209 ++++++++++++++++++++++ scripts/recipe-runtime-evidence-smoke.ts | 9 + scripts/run-registry-smoke.ts | 68 +++++++ 10 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/runs.ts create mode 100644 packages/runtime-core/src/run-registry.ts create mode 100644 scripts/run-registry-smoke.ts diff --git a/package.json b/package.json index e54d6fc..60e6e4f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "host-tool-registry-smoke": "tsx scripts/host-tool-registry-smoke.ts", "sandbox-tool-policy-smoke": "tsx scripts/sandbox-tool-policy-smoke.ts", "task-input-contract-smoke": "tsx scripts/task-input-contract-smoke.ts", + "run-registry-smoke": "tsx scripts/run-registry-smoke.ts", "runtime-episode-smoke": "tsx scripts/runtime-episode-smoke.ts", "runtime-snapshot-restore-smoke": "tsx scripts/runtime-snapshot-restore-smoke.ts", "runtime-action-adapter-smoke": "tsx scripts/runtime-action-adapter-smoke.ts", diff --git a/packages/cli/src/command-router.ts b/packages/cli/src/command-router.ts index c41fdf2..7a25abb 100644 --- a/packages/cli/src/command-router.ts +++ b/packages/cli/src/command-router.ts @@ -10,6 +10,8 @@ interface CliCommandRouter { recipeRun: CliCommandHandler workspacePolicyCheck: CliCommandHandler artifactsVerify: CliCommandHandler + runsStatus: CliCommandHandler + runsArtifacts: CliCommandHandler commands: CliCommandHandler recipeSchema: CliCommandHandler run: CliCommandHandler @@ -63,6 +65,18 @@ export async function routeCliCommand(argv: string[], router: CliCommandRouter): } return router.artifactsVerify(args) } + case "runs": { + const subcommand = args.shift() + if (subcommand === "status") { + return router.runsStatus(args) + } + if (subcommand === "artifacts") { + return router.runsArtifacts(args) + } + console.error(`Unknown runs command: ${subcommand ?? ""}`) + router.printHelp() + return 1 + } case "commands": return router.commands(args) case "schema": { diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index faf1e17..4fc9c56 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises" import { basename, dirname, resolve } from "node:path" -import { createRuntime, stripUndefined, type ArtifactBundle, type ExecutionResult, type Runtime, type RuntimeInfo, type WorkspaceRecipe, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeSiteSeed } from "@chubes4/wp-codebox-core" +import { RuntimeRunRegistry, artifactBundleRunRef, createRuntimeRunId, defaultRunRegistryDirectory, createRuntime, stripUndefined, type ArtifactBundle, type ExecutionResult, type Runtime, type RuntimeInfo, type RuntimeRunRecord, type WorkspaceRecipe, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeSiteSeed } from "@chubes4/wp-codebox-core" import { createPlaygroundRuntimeBackend } from "@chubes4/wp-codebox-playground" import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js" import { captureStdout, printRecipeHumanOutput, printRecipeValidateHumanOutput, serializeError } from "../output.js" @@ -14,6 +14,7 @@ import { DEFAULT_WORDPRESS_VERSION, previewSpec, releaseRuntime, runtimeMetadata interface RecipeRunOptions { recipePath: string artifactsDirectory?: string + runRegistryDirectory?: string previewHoldSeconds?: number previewPublicUrl?: string previewPort?: number @@ -75,6 +76,7 @@ interface RecipeRunOutput { benchResults?: BenchResults benchResultsList?: BenchResults[] artifacts?: ArtifactBundle + run?: RuntimeRunRecord interruption?: RecipeInterruptionMetadata logs?: string[] error?: RunOutput["error"] @@ -291,14 +293,37 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru const recipePath = resolve(options.recipePath) const recipeDirectory = dirname(recipePath) const recipe = parseWorkspaceRecipe(await readFile(recipePath, "utf8"), recipePath) + const configuredArtifactsDirectory = options.artifactsDirectory ?? recipe.artifacts?.directory + const runRegistry = new RuntimeRunRegistry(options.runRegistryDirectory ?? defaultRunRegistryDirectory(configuredArtifactsDirectory)) + let runRecord = await runRegistry.create({ + runId: createRuntimeRunId(), + status: "queued", + metadata: { + kind: "recipe-run", + recipePath, + artifactsDirectory: configuredArtifactsDirectory, + }, + replay: { + command: ["wp-codebox", "recipe-run", "--recipe", recipePath], + recipePath, + }, + }) const issues = await validateWorkspaceRecipe(recipe, recipePath) if (issues.length > 0) { + runRecord = await runRegistry.update(runRecord.runId, { + status: "failed", + error: { + name: "RecipeValidationError", + message: `Recipe validation failed with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`, + }, + }) return { success: false, schema: "wp-codebox/recipe-run/v1", recipePath, executions: [], validation: { issues }, + run: runRecord, error: { name: "RecipeValidationError", message: `Recipe validation failed with ${issues.length} issue${issues.length === 1 ? "" : "s"}.`, @@ -325,6 +350,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru overlays = await prepareRecipeRuntimeOverlaysForRun(recipe, recipeDirectory) interruption?.throwIfInterrupted() + runRecord = await runRegistry.update(runRecord.runId, { status: "booting" }) runtime = await awaitRecipe(createRuntime( { backend: recipe.runtime?.backend ?? "wordpress-playground", @@ -336,15 +362,17 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru }, policy: effectivePolicy, secretEnv, - artifactsDirectory: options.artifactsDirectory ?? recipe.artifacts?.directory, + artifactsDirectory: configuredArtifactsDirectory, metadata: { - ...runtimeMetadata(options.artifactsDirectory ?? recipe.artifacts?.directory, recipe.runtime?.wp ?? DEFAULT_WORDPRESS_VERSION), + ...runtimeMetadata(configuredArtifactsDirectory, recipe.runtime?.wp ?? DEFAULT_WORDPRESS_VERSION), + run: { runId: runRecord.runId, registryDirectory: runRegistry.directory }, ...recipeRunMetadata(recipe, recipePath, workspaceMounts, extraPlugins, stagedFiles, overlays, options.previewPublicUrl, options.previewPort, options.previewBind), }, preview: previewSpec(options.previewPublicUrl, options.previewPort, options.previewBind), }, createPlaygroundRuntimeBackend(), )) + runRecord = await runRegistry.update(runRecord.runId, { status: "running", runtime: await runtime.info() }) interruption?.throwIfInterrupted() for (const [index, mount] of (recipe.runtime?.stack?.mounts ?? []).entries()) { @@ -448,6 +476,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru await awaitRecipe(runtime.observe({ type: "runtime-info" })) await awaitRecipe(runtime.observe({ type: "mounts" })) + runRecord = await runRegistry.update(runRecord.runId, { status: "collecting_artifacts", runtime: await runtime.info() }) artifacts = await runtime.collectArtifacts({ includeLogs: true, includeObservations: true, previewHoldSeconds: options.previewHoldSeconds }) const evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnv) const agentEvidence = await finalizeAgentSandboxEvidence(artifacts, executions) @@ -464,6 +493,13 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru .map((execution) => parseBenchResults(execution.stdout)) if (strictFailure || agentFailure) { + runRecord = await runRegistry.update(runRecord.runId, { + status: "failed", + runtime: runtimeInfo ?? await runtime.info(), + preview: artifacts.preview, + artifactRefs: artifactBundleRunRef(artifacts), + error: strictFailure ?? agentFailure, + }) return { success: false, schema: "wp-codebox/recipe-run/v1", @@ -476,10 +512,17 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru ...(benchResultsList.length > 0 ? { benchResultsList } : {}), ...(evidence.agentResult ? { agentResult: recipeAgentResultOutput(evidence.agentResult) } : {}), artifacts, + run: runRecord, error: strictFailure ?? agentFailure, } } + runRecord = await runRegistry.update(runRecord.runId, { + status: "succeeded", + runtime: runtimeInfo ?? await runtime.info(), + preview: artifacts.preview, + artifactRefs: artifactBundleRunRef(artifacts), + }) return { success: true, schema: "wp-codebox/recipe-run/v1", @@ -492,6 +535,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru ...(benchResultsList.length > 0 ? { benchResultsList } : {}), ...(evidence.agentResult ? { agentResult: recipeAgentResultOutput(evidence.agentResult) } : {}), artifacts, + run: runRecord, } } catch (error) { if (runtime) { @@ -515,6 +559,12 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru } await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays) + runRecord = await runRegistry.update(runRecord.runId, { + status: interruption?.metadata ? "cancelled" : "failed", + ...(runtime ? { runtime: await runtime.info() } : {}), + ...(artifacts ? { preview: artifacts.preview, artifactRefs: artifactBundleRunRef(artifacts) } : {}), + error: serializeError(error), + }) return { success: false, @@ -524,6 +574,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru executions, diagnostics: recipeRuntimeDiagnostics(recipe, executions, error), ...(artifacts ? { artifacts } : {}), + run: runRecord, ...(interruption?.metadata ? { interruption: interruption.metadata } : {}), error: serializeError(error), } @@ -634,6 +685,9 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions { case "--artifacts": options.artifactsDirectory = value break + case "--run-registry": + options.runRegistryDirectory = value + break case "--preview-hold": options.previewHoldSeconds = parsePreviewHoldSeconds(value) break diff --git a/packages/cli/src/commands/runs.ts b/packages/cli/src/commands/runs.ts new file mode 100644 index 0000000..8963aee --- /dev/null +++ b/packages/cli/src/commands/runs.ts @@ -0,0 +1,96 @@ +import { RuntimeRunRegistry, type RuntimeRunRecord } from "@chubes4/wp-codebox-core" + +interface RunLookupOptions { + registryDirectory: string + runId: string + json: boolean +} + +export async function runRunsStatusCommand(args: string[]): Promise { + const options = parseRunLookupOptions(args) + const record = await new RuntimeRunRegistry(options.registryDirectory).read(options.runId) + if (!options.json) { + printRunStatusHumanOutput(record) + return 0 + } + + process.stdout.write(`${JSON.stringify(record, null, 2)}\n`) + return 0 +} + +export async function runRunsArtifactsCommand(args: string[]): Promise { + const options = parseRunLookupOptions(args) + const record = await new RuntimeRunRegistry(options.registryDirectory).read(options.runId) + const output = { + schema: "wp-codebox/run-artifacts/v1", + runId: record.runId, + status: record.status, + artifactRefs: record.artifactRefs, + } + if (!options.json) { + console.log(`WP Codebox run artifacts: ${record.runId}`) + console.log(`Status: ${record.status}`) + for (const artifact of record.artifactRefs) { + console.log(`${artifact.kind}: ${artifact.directory ?? artifact.path ?? artifact.id ?? "unknown"}`) + } + return 0 + } + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`) + return 0 +} + +function parseRunLookupOptions(args: string[]): RunLookupOptions { + const options: Partial = { json: false } + + for (let index = 0; index < args.length; index++) { + const arg = args[index] + + if (arg === "--json") { + options.json = true + continue + } + + const [name, inlineValue] = arg.split("=", 2) + const value = inlineValue ?? args[++index] + + if (!name.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument: ${arg}`) + } + + switch (name) { + case "--registry": + case "--run-registry": + options.registryDirectory = value + break + case "--run-id": + options.runId = value + break + default: + throw new Error(`Unknown option: ${name}`) + } + } + + if (!options.registryDirectory) { + throw new Error("Missing required option: --registry") + } + + if (!options.runId) { + throw new Error("Missing required option: --run-id") + } + + return options as RunLookupOptions +} + +function printRunStatusHumanOutput(record: RuntimeRunRecord): void { + console.log(`WP Codebox run: ${record.runId}`) + console.log(`Status: ${record.status}`) + console.log(`Heartbeat: ${record.heartbeatAt}`) + console.log(`Artifacts: ${record.artifactRefs.length}`) + if (record.preview?.url) { + console.log(`Preview: ${record.preview.url} (${record.preview.status})`) + } + if (record.error?.message) { + console.log(`Error: ${record.error.message}`) + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 07dc5e1..c04ffbb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { runArtifactsVerifyCommand } from "./commands/artifacts.js" import { runCommandsCommand, runRecipeSchemaCommand } from "./commands/discovery.js" import { runRecipeRunCommand, runRecipeValidateCommand } from "./commands/recipe-run.js" import { runBootCommand, runRunCommand, runValidateBlueprintCommand } from "./commands/runtime.js" +import { runRunsArtifactsCommand, runRunsStatusCommand } from "./commands/runs.js" import { runWorkspacePolicyCheckCommand } from "./commands/workspace-policy.js" import { printHelp, serializeError } from "./output.js" @@ -16,6 +17,8 @@ async function runCli(args: string[]): Promise { recipeValidate: runRecipeValidateCommand, workspacePolicyCheck: runWorkspacePolicyCheckCommand, artifactsVerify: runArtifactsVerifyCommand, + runsStatus: runRunsStatusCommand, + runsArtifacts: runRunsArtifactsCommand, commands: runCommandsCommand, recipeSchema: runRecipeSchemaCommand, run: runRunCommand, diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index fdb55c3..b2f72a2 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -243,6 +243,8 @@ export function printHelp(): void { wp-codebox workspace-policy check --workspace-root --writable-root [options] wp-codebox recipe validate --recipe [--json] wp-codebox artifacts verify --bundle [--json] + wp-codebox runs status --registry --run-id [--json] + wp-codebox runs artifacts --registry --run-id [--json] wp-codebox validate-blueprint --blueprint [options] wp-codebox recipe-run --recipe [options] wp-codebox boot [--mount :] [options] @@ -252,6 +254,10 @@ Options: --recipe Workspace recipe JSON file for recipe-run or recipe validate. --bundle Artifact bundle directory for artifacts verify. --artifacts Artifact root directory. Also accepted by artifacts verify. + --run-registry + Durable run registry directory for recipe-run. + --registry Durable run registry directory for runs lookup commands. + --run-id Run ID for runs lookup commands. --mount Mount a host path into the runtime. Repeatable. --command Command/action id to execute. --arg Command argument. Repeatable. Recipe commands include wordpress.run-php, wordpress.phpunit, wordpress.core-phpunit, wordpress.plugin-check, wordpress.wp-cli, wordpress.ability, wordpress.bench, and wordpress.browser-probe. diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index de75c93..494326d 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -15,6 +15,7 @@ export * from "./object-utils.js" export * from "./runtime-action-adapter.js" export * from "./artifact-bundle-verifier.js" export * from "./host-tool-registry.js" +export * from "./run-registry.js" export type ArtifactReviewProgressEventType = | "boot" diff --git a/packages/runtime-core/src/run-registry.ts b/packages/runtime-core/src/run-registry.ts new file mode 100644 index 0000000..7813408 --- /dev/null +++ b/packages/runtime-core/src/run-registry.ts @@ -0,0 +1,209 @@ +import { randomUUID } from "node:crypto" +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" + +import type { ArtifactBundle, ArtifactPreview, RuntimeInfo } from "./runtime-contracts.js" +import { stripUndefined } from "./object-utils.js" + +export type RuntimeRunStatus = "queued" | "booting" | "running" | "collecting_artifacts" | "succeeded" | "failed" | "timed_out" | "cancelled" + +export interface RuntimeRunArtifactRef { + kind: "artifact-bundle" | "command-log" | "transcript" | (string & {}) + path?: string + directory?: string + id?: string + digest?: { + algorithm: "sha256" | (string & {}) + value: string + } +} + +export interface RuntimeRunRetention { + cleanupEligibleAt?: string + retainUntil?: string + retained?: boolean + reason?: string +} + +export interface RuntimeRunReplay { + command?: string[] + recipePath?: string + ref?: string +} + +export interface RuntimeRunRecord { + schema: "wp-codebox/run-registry-entry/v1" + runId: string + status: RuntimeRunStatus + createdAt: string + updatedAt: string + heartbeatAt: string + runtime?: RuntimeInfo + preview?: ArtifactPreview + metadata?: Record + artifactRefs: RuntimeRunArtifactRef[] + retention?: RuntimeRunRetention + replay?: RuntimeRunReplay + error?: { + name: string + message: string + code?: string + } +} + +export interface RuntimeRunRegistryCreateOptions { + runId?: string + status?: RuntimeRunStatus + runtime?: RuntimeInfo + preview?: ArtifactPreview + metadata?: Record + retention?: RuntimeRunRetention + replay?: RuntimeRunReplay + artifactRefs?: RuntimeRunArtifactRef[] + now?: Date +} + +export interface RuntimeRunRegistryUpdate { + status?: RuntimeRunStatus + runtime?: RuntimeInfo + preview?: ArtifactPreview + metadata?: Record + retention?: RuntimeRunRetention + replay?: RuntimeRunReplay + artifactRefs?: RuntimeRunArtifactRef[] + error?: RuntimeRunRecord["error"] + heartbeat?: boolean + now?: Date +} + +export class RuntimeRunRegistry { + readonly directory: string + + constructor(directory: string) { + this.directory = resolve(directory) + } + + async create(options: RuntimeRunRegistryCreateOptions = {}): Promise { + const now = (options.now ?? new Date()).toISOString() + const record: RuntimeRunRecord = { + schema: "wp-codebox/run-registry-entry/v1", + runId: options.runId ?? createRuntimeRunId(), + status: options.status ?? "queued", + createdAt: now, + updatedAt: now, + heartbeatAt: now, + artifactRefs: options.artifactRefs ?? [], + ...stripUndefined({ + runtime: options.runtime, + preview: options.preview, + metadata: sanitizeRunMetadata(options.metadata), + retention: options.retention, + replay: options.replay, + }), + } + await this.write(record) + return record + } + + async read(runId: string): Promise { + return JSON.parse(await readFile(this.recordPath(runId), "utf8")) as RuntimeRunRecord + } + + async update(runId: string, update: RuntimeRunRegistryUpdate): Promise { + const current = await this.read(runId) + const now = (update.now ?? new Date()).toISOString() + const next: RuntimeRunRecord = { + ...current, + status: update.status ?? current.status, + ...(update.runtime ? { runtime: update.runtime } : {}), + ...(update.preview ? { preview: update.preview } : {}), + ...(update.metadata ? { metadata: sanitizeRunMetadata({ ...(current.metadata ?? {}), ...update.metadata }) } : {}), + ...(update.retention ? { retention: update.retention } : {}), + ...(update.replay ? { replay: update.replay } : {}), + ...(update.artifactRefs ? { artifactRefs: update.artifactRefs } : {}), + ...(update.error ? { error: update.error } : {}), + updatedAt: now, + heartbeatAt: update.heartbeat === false ? current.heartbeatAt : now, + } + await this.write(next) + return next + } + + async heartbeat(runId: string, now = new Date()): Promise { + return this.update(runId, { now, heartbeat: true }) + } + + recordPath(runId: string): string { + assertSafeRunId(runId) + return join(this.directory, `${runId}.json`) + } + + private async write(record: RuntimeRunRecord): Promise { + await mkdir(this.directory, { recursive: true }) + const target = this.recordPath(record.runId) + const temp = join(dirname(target), `.${record.runId}.${process.pid}.tmp`) + await writeFile(temp, `${JSON.stringify(record, null, 2)}\n`) + await rename(temp, target) + } +} + +export function createRuntimeRunId(): string { + return `run_${randomUUID().replaceAll("-", "")}` +} + +export function artifactBundleRunRef(bundle: ArtifactBundle | undefined): RuntimeRunArtifactRef[] { + if (!bundle) { + return [] + } + + return [ + stripUndefined({ + kind: "artifact-bundle" as const, + directory: bundle.directory, + id: bundle.id, + digest: bundle.contentDigest ? { algorithm: "sha256" as const, value: bundle.contentDigest } : undefined, + }), + ] +} + +export function defaultRunRegistryDirectory(artifactsDirectory: string | undefined, cwd = process.cwd()): string { + return resolve(artifactsDirectory ?? join(cwd, "artifacts"), "runs") +} + +function sanitizeRunMetadata(metadata: Record | undefined): Record | undefined { + if (!metadata) { + return undefined + } + + return sanitizeValue(metadata) as Record +} + +function sanitizeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sanitizeValue) + } + + if (!value || typeof value !== "object") { + return value + } + + const sanitized: Record = {} + for (const [key, child] of Object.entries(value)) { + if (isSensitiveMetadataKey(key)) { + sanitized[key] = "[redacted]" + } else { + sanitized[key] = sanitizeValue(child) + } + } + return sanitized +} + +function isSensitiveMetadataKey(key: string): boolean { + return /secret|token|credential|password|api[_-]?key/i.test(key) +} + +function assertSafeRunId(runId: string): void { + if (!/^[A-Za-z0-9_.-]+$/.test(runId)) { + throw new Error(`Invalid run id: ${runId}`) + } +} diff --git a/scripts/recipe-runtime-evidence-smoke.ts b/scripts/recipe-runtime-evidence-smoke.ts index 1b5e144..3b8e5f4 100644 --- a/scripts/recipe-runtime-evidence-smoke.ts +++ b/scripts/recipe-runtime-evidence-smoke.ts @@ -24,6 +24,14 @@ try { const passing = await recipeRun(passingRecipe) assert.equal(passing.success, true, passing.error?.message) assert.ok(passing.artifacts?.directory, "recipe-run should return an artifact directory") + assert.match(passing.run?.runId ?? "", /^run_[a-f0-9]{32}$/) + assert.equal(passing.run?.status, "succeeded") + assert.equal(passing.run?.artifactRefs?.[0]?.kind, "artifact-bundle") + assert.equal(passing.run?.artifactRefs?.[0]?.directory, passing.artifacts.directory) + + const passingRunRegistryEntry = JSON.parse(await readFile(join(passingArtifacts, "runs", `${passing.run.runId}.json`), "utf8")) + assert.equal(passingRunRegistryEntry.status, "succeeded") + assert.equal(passingRunRegistryEntry.artifactRefs[0].id, passing.artifacts.id) const passingManifest = JSON.parse(await readFile(join(passing.artifacts.directory, "manifest.json"), "utf8")) assertManifestFile(passingManifest, "files/runtime-evidence/run-attestation.json", "run-attestation") @@ -81,6 +89,7 @@ try { const failing = await recipeRun(failingRecipe, false) assert.equal(failing.success, false) assert.equal(failing.error?.code, "workspace-policy-failed") + assert.equal(failing.run?.status, "failed") assert.ok(failing.artifacts?.directory, "failing strict policy run should keep artifacts") const failingPolicy = JSON.parse(await readFile(join(failing.artifacts.directory, "files/runtime-evidence/workspace-policy.json"), "utf8")) assert.equal(failingPolicy.passed, false) diff --git a/scripts/run-registry-smoke.ts b/scripts/run-registry-smoke.ts new file mode 100644 index 0000000..e020055 --- /dev/null +++ b/scripts/run-registry-smoke.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { promisify } from "node:util" + +import { RuntimeRunRegistry, artifactBundleRunRef } from "../packages/runtime-core/src/run-registry.js" + +const execFileAsync = promisify(execFile) +const root = resolve(import.meta.dirname, "..") +const workspace = await mkdtemp(join(tmpdir(), "wp-codebox-run-registry-")) + +try { + const registryDirectory = join(workspace, "runs") + const registry = new RuntimeRunRegistry(registryDirectory) + const run = await registry.create({ + runId: "run_smoke", + status: "queued", + metadata: { + kind: "smoke", + secretEnv: { OPENAI_API_KEY: "should-not-persist" }, + nested: { token: "should-not-persist" }, + }, + replay: { command: ["wp-codebox", "recipe-run", "--recipe", "recipe.json"] }, + }) + + assert.equal(run.schema, "wp-codebox/run-registry-entry/v1") + assert.equal(run.status, "queued") + assert.equal(run.metadata?.secretEnv, "[redacted]") + assert.equal((run.metadata?.nested as { token?: string }).token, "[redacted]") + + const artifactDirectory = join(workspace, "artifact-bundle") + await mkdir(artifactDirectory, { recursive: true }) + await writeFile(join(artifactDirectory, "manifest.json"), "{}\n") + + const updated = await registry.update(run.runId, { + status: "succeeded", + artifactRefs: artifactBundleRunRef({ + id: "artifact-smoke", + directory: artifactDirectory, + manifestPath: join(artifactDirectory, "manifest.json"), + contentDigest: "a".repeat(64), + } as any), + now: new Date("2026-01-01T00:00:01.000Z"), + }) + assert.equal(updated.status, "succeeded") + assert.equal(updated.artifactRefs[0]?.kind, "artifact-bundle") + assert.equal(updated.artifactRefs[0]?.digest?.value, "a".repeat(64)) + assert.equal(updated.heartbeatAt, "2026-01-01T00:00:01.000Z") + + const persisted = JSON.parse(await readFile(join(registryDirectory, "run_smoke.json"), "utf8")) + assert.equal(persisted.status, "succeeded") + + const { stdout: statusStdout } = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "runs", "status", "--registry", registryDirectory, "--run-id", run.runId, "--json"], { cwd: root }) + const status = JSON.parse(statusStdout) + assert.equal(status.runId, run.runId) + assert.equal(status.status, "succeeded") + + const { stdout: artifactsStdout } = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "runs", "artifacts", "--registry", registryDirectory, "--run-id", run.runId, "--json"], { cwd: root }) + const artifacts = JSON.parse(artifactsStdout) + assert.equal(artifacts.schema, "wp-codebox/run-artifacts/v1") + assert.equal(artifacts.artifactRefs[0].id, "artifact-smoke") + + console.log("Run registry smoke passed") +} finally { + await rm(workspace, { recursive: true, force: true }) +}