Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/command-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface CliCommandRouter {
recipeRun: CliCommandHandler
workspacePolicyCheck: CliCommandHandler
artifactsVerify: CliCommandHandler
runsStatus: CliCommandHandler
runsArtifacts: CliCommandHandler
commands: CliCommandHandler
recipeSchema: CliCommandHandler
run: CliCommandHandler
Expand Down Expand Up @@ -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": {
Expand Down
60 changes: 57 additions & 3 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -75,6 +76,7 @@ interface RecipeRunOutput {
benchResults?: BenchResults
benchResultsList?: BenchResults[]
artifacts?: ArtifactBundle
run?: RuntimeRunRecord
interruption?: RecipeInterruptionMetadata
logs?: string[]
error?: RunOutput["error"]
Expand Down Expand Up @@ -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"}.`,
Expand All @@ -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",
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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),
}
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions packages/cli/src/commands/runs.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<number> {
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<RunLookupOptions> = { 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}`)
}
}
3 changes: 3 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -16,6 +17,8 @@ async function runCli(args: string[]): Promise<number> {
recipeValidate: runRecipeValidateCommand,
workspacePolicyCheck: runWorkspacePolicyCheckCommand,
artifactsVerify: runArtifactsVerifyCommand,
runsStatus: runRunsStatusCommand,
runsArtifacts: runRunsArtifactsCommand,
commands: runCommandsCommand,
recipeSchema: runRecipeSchemaCommand,
run: runRunCommand,
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ export function printHelp(): void {
wp-codebox workspace-policy check --workspace-root <path> --writable-root <path> [options]
wp-codebox recipe validate --recipe <path> [--json]
wp-codebox artifacts verify --bundle <dir> [--json]
wp-codebox runs status --registry <dir> --run-id <id> [--json]
wp-codebox runs artifacts --registry <dir> --run-id <id> [--json]
wp-codebox validate-blueprint --blueprint <json|file> [options]
wp-codebox recipe-run --recipe <path> [options]
wp-codebox boot [--mount <host>:<vfs>] [options]
Expand All @@ -252,6 +254,10 @@ Options:
--recipe <path> Workspace recipe JSON file for recipe-run or recipe validate.
--bundle <dir> Artifact bundle directory for artifacts verify.
--artifacts <dir> Artifact root directory. Also accepted by artifacts verify.
--run-registry <dir>
Durable run registry directory for recipe-run.
--registry <dir> Durable run registry directory for runs lookup commands.
--run-id <id> Run ID for runs lookup commands.
--mount <host:vfs> Mount a host path into the runtime. Repeatable.
--command <id> Command/action id to execute.
--arg <key=value> 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.
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading