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
12 changes: 12 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ on:
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
- "tests/playground-phpunit-readonly-cache.integration.test.ts"
- "tests/fixtures/phpunit-playground-harness/**"
- "packages/cli/src/bounded-recipe-plan.ts"
- "packages/runtime-core/src/bounded-runtime-plan.ts"
- "tests/bounded-*.test.ts"
- "tests/disposable-mysql-mysqli.integration.test.ts"
push:
paths:
- ".github/scripts/run-agent-task/**"
Expand All @@ -51,6 +55,10 @@ on:
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
- "tests/playground-phpunit-readonly-cache.integration.test.ts"
- "tests/fixtures/phpunit-playground-harness/**"
- "packages/cli/src/bounded-recipe-plan.ts"
- "packages/runtime-core/src/bounded-runtime-plan.ts"
- "tests/bounded-*.test.ts"
- "tests/disposable-mysql-mysqli.integration.test.ts"

jobs:
contracts:
Expand All @@ -70,6 +78,10 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm run test:agent-task-contracts
- run: npm run test:bounded-runtime-plan
- run: npm run test:bounded-recipe-plan
- run: npm run test:bounded-recipe-plan-integration
- run: npm run test:disposable-mysql-mysqli-e2e
- run: npm run test:runtime-sources-playground-integration
- run: npm run test:playground-phpunit-readonly-cache-integration
- run: npm run test:native-agent-task-playground-e2e
Expand Down
13 changes: 12 additions & 1 deletion docs/public-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ Use these package entrypoints from external integrations:
concurrency. It accepts `wp-codebox/bounded-runtime-plan/v1`; every entry has
`argv`, string-only environment overrides, a per-entry timeout, process
identity, safe artifact namespace, and input index. Results use
`wp-codebox/bounded-runtime-plan-result/v1` in input order. The adapter owns
`wp-codebox/bounded-runtime-plan-result/v1` in input order with aggregate
counts, per-entry duration, and optional stdout, stderr, result, and artifact
refs. The adapter owns
runtime-specific execution, while Codebox guarantees one materialize/start and
one stop/dispose lifecycle. Set `failFast: true` to cancel not-yet-started
entries, and use `retryBoundedRuntimePlan(plan, priorResult)` to select only
Expand All @@ -66,6 +68,15 @@ Use these package entrypoints from external integrations:
- `@automattic/wp-codebox-cli/recipe-secret-env`: recipe secret environment
resolution helpers for CLI consumers that need dry-run summaries or runtime
environment injection outside the command entrypoint.
- `@automattic/wp-codebox-cli/bounded-recipe-plan` and the matching workspace
`./cli/bounded-recipe-plan` entrypoint execute a bounded plan inside an already
prepared recipe runtime. Recipes use the generic
`wp-codebox.bounded-runtime-plan` helper with `plan-json` or `plan-file`; nested
commands remain subject to the recipe runtime policy, entry environment values
are applied as per-execution runtime environment overrides without appearing in
command result arguments, and outputs are persisted under
each safe artifact namespace. Running that recipe through `wp-codebox
recipe-run` preserves one outer runtime and disposable-service lifecycle.

Browser sessions that load the WordPress plugin browser runtime also publish
`window.wpCodeboxBrowser.v1`. The `v1` facade is the stable browser SDK
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"./cli/recipe-secret-env": {
"types": "./packages/cli/dist/recipe-secret-env.d.ts",
"import": "./packages/cli/dist/recipe-secret-env.js"
},
"./cli/bounded-recipe-plan": {
"types": "./packages/cli/dist/bounded-recipe-plan.d.ts",
"import": "./packages/cli/dist/bounded-recipe-plan.js"
}
},
"bin": {
Expand Down Expand Up @@ -127,6 +131,8 @@
"test:run-registry": "tsx tests/run-registry.test.ts",
"test:run-plan-executor": "tsx tests/run-plan-executor.test.ts",
"test:bounded-runtime-plan": "tsx tests/bounded-runtime-plan.test.ts",
"test:bounded-recipe-plan": "tsx tests/bounded-recipe-plan.test.ts",
"test:bounded-recipe-plan-integration": "tsx tests/bounded-recipe-plan.integration.test.ts",
"test:fanout-execution": "tsx tests/fanout-execution.test.ts",
"test:agent-fanout-executor": "tsx tests/agent-fanout-executor.test.ts",
"test:fanout-aggregation-contract-parity": "tsx tests/fanout-aggregation-contract-parity.test.ts",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
"./recipe-secret-env": {
"types": "./dist/recipe-secret-env.d.ts",
"import": "./dist/recipe-secret-env.js"
},
"./bounded-recipe-plan": {
"types": "./dist/bounded-recipe-plan.d.ts",
"import": "./dist/bounded-recipe-plan.js"
}
},
"files": [
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/bounded-recipe-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { join } from "node:path"
import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core"
import { recipeExecutionSpec } from "./agent-sandbox.js"

export interface BoundedRecipePlanExecutionOptions {
artifactRoot: string
recipeDirectory: string
}

export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRuntimePlan, options: BoundedRecipePlanExecutionOptions): Promise<BoundedRuntimePlanResult> {
const aggregate = await executeBoundedRuntimePlan(plan, {
async materialize() { return { workspace: options.recipeDirectory, runtime } },
async startServices() { return undefined },
async execute({ entry, signal }) {
const command = entry.argv[0]?.trim()
if (!command) throw new Error(`Bounded recipe plan entry requires argv[0] command: ${entry.id}`)
const args = entry.argv.slice(1)
const directory = join(options.artifactRoot, entry.artifactNamespace)
const stdoutRef = `${entry.artifactNamespace}/stdout.txt`
const stderrRef = `${entry.artifactNamespace}/stderr.txt`
const resultRef = `${entry.artifactNamespace}/result.json`
await mkdir(directory, { recursive: true })
let execution: ExecutionResult | undefined
let message = ""
try {
const spec = await recipeExecutionSpec({ command, args }, options.recipeDirectory)
execution = await runtime.execute({ ...spec, environment: entry.environment, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, timeoutMs: entry.timeoutMs, signal })
} catch (error) {
message = error instanceof Error ? error.message : String(error)
}
const stdout = redactOutput(execution?.stdout ?? "", entry.environment)
const stderr = redactOutput(execution?.stderr || message, entry.environment)
const exitCode = execution?.exitCode ?? 1
const artifactRefs = runtimeArtifactRefs(execution)
await writeFile(join(options.artifactRoot, stdoutRef), stdout, "utf8")
await writeFile(join(options.artifactRoot, stderrRef), stderr, "utf8")
await writeFile(join(options.artifactRoot, resultRef), `${JSON.stringify({
schema: "wp-codebox/bounded-runtime-plan-entry-result/v1",
id: entry.id,
inputIndex: entry.inputIndex,
processIdentity: entry.processIdentity,
artifactNamespace: entry.artifactNamespace,
command,
exitCode,
success: exitCode === 0,
stdoutRef,
stderrRef,
artifactRefs,
}, null, 2)}\n`, "utf8")
return { success: exitCode === 0, exitCode, message: stderr, stdoutRef, stderrRef, resultRef, artifactRefs }
},
async stopServices() {},
async dispose() {},
})
await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true })
await writeFile(join(options.artifactRoot, "bounded-plan/result.json"), `${JSON.stringify(aggregate, null, 2)}\n`, "utf8")
return aggregate
}

export async function executeBoundedRecipePlanFromArgs(runtime: Runtime, args: string[], options: BoundedRecipePlanExecutionOptions): Promise<BoundedRuntimePlanResult> {
const raw = commandArgValue(args, "plan-json") || commandArgValue(args, "request-json")
const file = commandArgValue(args, "plan-file") || commandArgValue(args, "request-file")
if (!raw && !file) throw new Error("wp-codebox.bounded-runtime-plan requires plan-json=<json> or plan-file=<recipe-relative-path>")
const text = raw ?? await readFile(join(options.recipeDirectory, file!), "utf8")
return executeBoundedRecipePlan(runtime, parseCommandJsonObject(text, "wp-codebox.bounded-runtime-plan plan") as unknown as BoundedRuntimePlan, options)
}

function redactOutput(output: string, environment: Record<string, string> | undefined): string {
return Object.values(environment ?? {}).reduce((redacted, value) => value ? redacted.split(value).join("[redacted]") : redacted, output)
}

function runtimeArtifactRefs(execution: ExecutionResult | undefined): string[] {
return (execution?.artifactRefs ?? []).flatMap((reference) => {
const value = reference.path ?? reference.id
return typeof value === "string" && value ? [value] : []
})
}
20 changes: 20 additions & 0 deletions packages/cli/src/commands/recipe-run-workflow-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createWordPressFuzzSuiteResetExecutor } from "@automattic/wp-codebox-pl
import { correlateObservedHostsToExternalServiceBoundaries, recipeExternalServiceBoundarySummaries } from "../recipe-external-services.js"
import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js"
import { executeAgentFanoutFromArgs } from "../agent-fanout.js"
import { executeBoundedRecipePlanFromArgs } from "../bounded-recipe-plan.js"
import { recipeWorkflowSteps, type RecipeWorkflowPhase } from "../recipe-validation.js"
import { artifactManifestFilesByPath } from "./recipe-run-benchmark-artifacts.js"
import { assertResolvedInputMountPathArgs, rewriteInputMountPathArgs, rewriteInputMountPathJsonArgs, type InputMountPathMapping } from "../input-mount-paths.js"
Expand Down Expand Up @@ -244,6 +245,25 @@ export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep:
...(workflowStep.fuzzStepIndex !== undefined ? { fuzzStepIndex: workflowStep.fuzzStepIndex } : {}),
}
}
if (step.command === "wp-codebox.bounded-runtime-plan") {
const startedAt = new Date().toISOString()
const result = await executeBoundedRecipePlanFromArgs(runtime, step.args ?? [], {
artifactRoot: artifactRoot || recipeDirectory,
recipeDirectory,
})
const safeArgs = (step.args ?? []).map((argument) => argument.startsWith("plan-json=") || argument.startsWith("request-json=") ? `${argument.slice(0, argument.indexOf("=") + 1)}[redacted-plan]` : argument)
return phase({
id: `bounded-runtime-plan-${workflowStep.index}`,
command: step.command,
args: safeArgs,
exitCode: result.success ? 0 : 1,
stdout: `${JSON.stringify(result, null, 2)}\n`,
stderr: "",
startedAt,
finishedAt: new Date().toISOString(),
artifactRefs: [{ kind: "bounded-runtime-plan-result", id: "bounded-runtime-plan-result", path: "bounded-plan/result.json" }],
}, step.command, recipeWorkflowArgsEvidence(safeArgs, safeArgs))
}
if (isRuntimeCheckpointRecipeCommand(step.command)) {
return phase(await executeRuntimeCheckpointRecipeCommand(runtime, step.command, step.args ?? []))
}
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy {
})
const commands = [
...effectivePolicyCommandsFor(recipeWorkflowSteps(recipe).map(({ step }) => step.command), cliRecipeCommandDefinitions),
...effectivePolicyCommandsFor(boundedRuntimePlanCommands(recipe), cliRecipeCommandDefinitions),
...effectivePolicyCommandsFor(pluginRuntimeCommands, cliRecipeCommandDefinitions),
...effectivePolicyCommandsFor(distributionStartupProbeCommands, cliRecipeCommandDefinitions),
...effectivePolicyCommandsFor((recipe.probes ?? []).map((probe) => probe.step.command), cliRecipeCommandDefinitions),
Expand Down Expand Up @@ -995,6 +996,20 @@ export function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy {
}
}

function boundedRuntimePlanCommands(recipe: WorkspaceRecipe): string[] {
return recipeWorkflowSteps(recipe).flatMap(({ step }) => {
if (step.command !== "wp-codebox.bounded-runtime-plan") return []
const raw = step.args?.find((argument) => argument.startsWith("plan-json="))?.slice("plan-json=".length)
if (!raw) return []
try {
const plan = JSON.parse(raw) as { entries?: Array<{ argv?: unknown[] }> }
return (plan.entries ?? []).flatMap((entry) => typeof entry.argv?.[0] === "string" ? [entry.argv[0]] : [])
} catch {
return []
}
})
}

function validateFixtureDatabases(fixtureDatabases: WorkspaceRecipeFixtureDatabase[] | undefined, recipePath: string): void {
if (fixtureDatabases && !Array.isArray(fixtureDatabases)) {
throw new Error(`Recipe fixtureDatabases must be an array: ${recipePath}`)
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
const { stdout } = await dependencies.execute("docker", ["port", container, "3306/tcp"], { signal, timeout: 10_000 })
const port = parseLoopbackPort(stdout)
await dependencies.waitForReady("127.0.0.1", port, 30_000, signal)
await waitForMysqlDatabase(container, engine, password, dependencies, 30_000, signal)
throwIfAborted(signal)
evidence.readiness = "ready"
evidence.lifecycle = "provisioned"
Expand All @@ -186,6 +187,23 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
}
}

async function waitForMysqlDatabase(container: string, engine: keyof typeof MYSQL_IMAGES, password: string, dependencies: RuntimeServiceDependencies, timeoutMs: number, signal?: AbortSignal): Promise<void> {
const deadline = Date.now() + timeoutMs
const client = engine === "mariadb" ? "mariadb" : "mysql"
const args = ["exec", "--env", "MYSQL_PWD", container, client, "--protocol=TCP", "--host=127.0.0.1", "--user=runtime", "--database=runtime", "--execute=SELECT 1"]
while (Date.now() < deadline) {
throwIfAborted(signal)
try {
await dependencies.execute("docker", args, { env: { ...process.env, MYSQL_PWD: password }, signal, timeout: 5_000 })
return
} catch (error) {
if (signal?.aborted) throw error
await abortableDelay(100, signal)
}
}
throw new Error(`MySQL database readiness timed out after ${timeoutMs}ms`)
}

async function ensureDockerImage(image: string, dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise<void> {
try {
await dependencies.execute("docker", ["image", "inspect", image], { signal, timeout: 10_000 })
Expand Down
Loading
Loading