From 764d3fe6569e7348f1b35b948238d71b1f113083 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 12:40:25 -0400 Subject: [PATCH] fix: add bounded recipe step continuation --- .github/workflows/agent-task-contracts.yml | 1 + docs/recipe-contract.md | 47 ++++ package.json | 1 + packages/cli/src/commands/recipe-run-types.ts | 18 ++ .../commands/recipe-run-workflow-evidence.ts | 214 +++++++++++++++++- packages/cli/src/commands/recipe-run.ts | 17 +- packages/cli/src/recipe-dry-run.ts | 2 + packages/runtime-core/src/recipe-schema.ts | 67 ++++++ .../runtime-core/src/runtime-contracts.ts | 20 ++ ...cipe-step-continuation.integration.test.ts | 119 ++++++++++ tests/recipe-step-continuation.test.ts | 137 +++++++++++ 11 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 tests/recipe-step-continuation.integration.test.ts create mode 100644 tests/recipe-step-continuation.test.ts diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 1883a5b44..aa93f301f 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -130,6 +130,7 @@ jobs: - 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:recipe-step-continuation - 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 diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index 512be2cb4..daddc48e3 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -125,6 +125,53 @@ Use `inputs.workspace_preloads` for generic `agent-runtime/workspace-preload` artifact contracts. WP Codebox materializes declared repositories as sandbox workspace mounts; callers own the policy that decides which artifacts to pass. +## Bounded Step Continuation + +A workflow step can repeat a JSON-producing command in the same runtime and map +values from one result into the next invocation. The contract uses RFC 6901 JSON +Pointers and always requires a finite `maxIterations`: + +```json +{ + "command": "wordpress.ability", + "args": [ + "name=example/resumable-operation", + "input={\"url\":\"https://example.com/\"}" + ], + "continuation": { + "maxIterations": 100, + "while": { + "pointer": "/result/continuation", + "equals": true + }, + "inputMappings": [ + { + "from": "/result/receipt", + "to": { + "arg": "input", + "pointer": "/receipt" + } + } + ] + } +} +``` + +The runner evaluates pointers against `execution.result.json`, falling back to a +JSON object parsed from stdout. Each mapping updates one JSON-valued `key=value` +argument while preserving all unrelated arguments. Missing or malformed values, +ambiguous target arguments, command failures, and a still-matched predicate at +the iteration limit fail closed. Continuation is unavailable for synthetic and +host-executed recipe helpers such as workload collection, workload manifests, +fanout, bounded plans, checkpoints, and fuzz suites. A continuation accepts at +most 64 input mappings; argument names and JSON Pointers are length-bounded. The +terminal command result stays compatible with normal step consumers. +Per-iteration evidence records argument and result SHA-256 digests, byte counts, +and a result preview only when it is at most 4 KiB. Predicate equality evidence +follows the same 4 KiB preview bound. Failed and timed-out steps retain partial +bounded `wp-codebox/recipe-continuation-evidence/v1` evidence in their failure +record. + ## Adversarial Campaigns `adversarialCampaigns` additively declares deterministic corpus campaigns without diff --git a/package.json b/package.json index a6f88ca55..2aa1704df 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "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:recipe-step-continuation": "tsx tests/recipe-step-continuation.test.ts && tsx tests/recipe-step-continuation.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", diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index 86cdec8ab..90c089dbe 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -149,8 +149,24 @@ export type RecipeExecutionResult = ExecutionResult & { fuzzCaseIndex?: number fuzzPhase?: WorkspaceRecipeFuzzCasePhase fuzzStepIndex?: number + continuationEvidence?: RecipeContinuationEvidence } +export interface RecipeContinuationEvidence { + schema: "wp-codebox/recipe-continuation-evidence/v1" + policy: { + maxIterations: number + while: { pointer: string; equalsBytes: number; equalsSha256: string; equals?: unknown; equalsTruncated?: boolean } + inputMappings: Array<{ from: string; to: { arg: string; pointer: string } }> + } + status: "completed" | "failed" | "exhausted" + iterations: number + executions: Array<{ iteration: number; exitCode: number; argsSha256: string; resultBytes: number; resultSha256: string; result?: unknown; resultTruncated?: boolean }> + diagnostics?: { code: string; message: string } +} + +export type RecipeContinuationProgress = Pick + export interface RecipeWorkflowArgsEvidence { schema: "wp-codebox/recipe-workflow-args/v1" original: string[] @@ -229,6 +245,7 @@ export interface RecipeAdvisoryFailure { command: string status: "failed" error: RunOutput["error"] + continuationEvidence?: RecipeContinuationEvidence } export interface RecipeStepFailure { @@ -243,6 +260,7 @@ export interface RecipeStepFailure { classification: "timeout" | "error" timeoutMs?: number error: RunOutput["error"] + continuationEvidence?: RecipeContinuationEvidence } export interface RecipeBrowserEvidenceFileRef { diff --git a/packages/cli/src/commands/recipe-run-workflow-evidence.ts b/packages/cli/src/commands/recipe-run-workflow-evidence.ts index a45619673..73b0391f5 100644 --- a/packages/cli/src/commands/recipe-run-workflow-evidence.ts +++ b/packages/cli/src/commands/recipe-run-workflow-evidence.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto" +import { isDeepStrictEqual } from "node:util" import { readFile } from "node:fs/promises" import { resolve } from "node:path" import { commandArgValue, parseCommandJson, parseCommandJsonObject, RUNTIME_BACKED_FUZZ_SUITE_RUNNER_CAPABILITIES, runFuzzSuite, runtimeCheckpointUnsupportedDiagnostic, type ArtifactBundle, type ArtifactManifestFile, type ExecutionResult, type FuzzSuiteContract, type Runtime, type RuntimeCheckpointFailureDiagnostic, type RuntimeCheckpointOperation, type RuntimeCheckpointResult, type WorkspaceRecipe, type WorkspaceRecipeDistributionSetupArtifact, type WorkspaceRecipeDistributionStartupProbe, type WorkspaceRecipeProbe } from "@automattic/wp-codebox-core" @@ -12,7 +13,7 @@ import { recipeWorkflowSteps, type RecipeWorkflowPhase } from "../recipe-validat import { artifactManifestFilesByPath } from "./recipe-run-benchmark-artifacts.js" import { assertResolvedInputMountPathArgs, rewriteInputMountPathArgs, rewriteInputMountPathJsonArgs, type InputMountPathMapping } from "../input-mount-paths.js" import { RecipeRunTimeoutError, serializeRecipeRunError } from "./recipe-run-output.js" -import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeBrowserEvidenceFileRef, RecipeExecutionResult, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunOptions, RecipeRunProbe, RecipeStepFailure, RecipeWorkflowArgsEvidence } from "./recipe-run-types.js" +import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeBrowserEvidenceFileRef, RecipeContinuationEvidence, RecipeContinuationProgress, RecipeExecutionResult, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunOptions, RecipeRunProbe, RecipeStepFailure, RecipeWorkflowArgsEvidence } from "./recipe-run-types.js" export function withRecipeExecutionPhase(execution: ExecutionResult, recipePhase: RecipeWorkflowPhase, recipeStepIndex: number, recipeCommand?: string, recipeArgs?: RecipeWorkflowArgsEvidence, recipeStepMetadata?: Record): RecipeExecutionResult { return { @@ -44,7 +45,8 @@ export function recipeWorkflowStepIsAdvisory(step: WorkspaceRecipe["workflow"][" return step.allowFailure === true || step.advisory === true } -export function recipeAdvisoryFailure(workflowStep: ReturnType[number], error: unknown): RecipeAdvisoryFailure { +export function recipeAdvisoryFailure(workflowStep: ReturnType[number], error: unknown, continuationProgress?: RecipeContinuationProgress): RecipeAdvisoryFailure { + const continuationEvidence = recipeFailureContinuationEvidence(error, continuationProgress) return { schema: "wp-codebox/recipe-advisory-failure/v1", phase: workflowStep.phase, @@ -52,11 +54,13 @@ export function recipeAdvisoryFailure(workflowStep: ReturnType[number], error: unknown, startedAtMs: number, finishedAtMs = Date.now()): RecipeStepFailure { +export function recipeStepFailure(workflowStep: ReturnType[number], error: unknown, startedAtMs: number, finishedAtMs = Date.now(), continuationProgress?: RecipeContinuationProgress): RecipeStepFailure { const timeoutError = recipeTimeoutError(error) + const continuationEvidence = recipeFailureContinuationEvidence(error, continuationProgress) return stripUndefined({ schema: "wp-codebox/recipe-step-failure/v1", phase: workflowStep.phase, @@ -69,15 +73,36 @@ export function recipeStepFailure(workflowStep: ReturnType { const manifestFiles = await artifactManifestFilesByPath(artifacts) return executions.flatMap((execution) => recipeBrowserEvidenceForExecution(execution, manifestFiles, recipe)) @@ -207,7 +232,7 @@ function recipeCommandProducesBrowserEvidence(command: string): boolean { return command.startsWith("wordpress.browser-") || command === "wordpress.editor-canvas-probe" || command === "wordpress.editor-validate-blocks" || command === "wordpress.html-capture" || command === "wordpress.visual-compare" } -export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep: ReturnType[number], recipeDirectory: string, sandboxWorkspace?: ReturnType, artifactRoot?: string, options?: RecipeRunOptions, inputMountPathMap: readonly InputMountPathMapping[] = []): Promise { +export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep: ReturnType[number], recipeDirectory: string, sandboxWorkspace?: ReturnType, artifactRoot?: string, options?: RecipeRunOptions, inputMountPathMap: readonly InputMountPathMapping[] = [], onContinuationProgress?: (progress: RecipeContinuationProgress) => void): Promise { const originalArgs = workflowStep.step.args ?? [] const resolvedArgs = rewriteWorkflowStepArgs(workflowStep.step.command, originalArgs, inputMountPathMap) assertResolvedInputMountPathArgs(resolvedArgs, inputMountPathMap, `Recipe workflow ${workflowStep.phase}[${workflowStep.index}] ${workflowStep.step.command}`) @@ -216,6 +241,12 @@ export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep: const mappedWorkflowStep = { ...workflowStep, step } const phase = (execution: ExecutionResult, command = step.command, evidence = argsEvidence) => withRecipeExecutionPhase(execution, workflowStep.phase, workflowStep.index, command, evidence, step.metadata) try { + if (step.continuation) { + if (recipeCommandHandlesItsOwnExecution(step.command)) { + throw new Error(`Continuation is unavailable for recipe command ${step.command}.`) + } + return await executeRecipeStepContinuation(runtime, mappedWorkflowStep, recipeDirectory, sandboxWorkspace, inputMountPathMap, onContinuationProgress) + } if (step.command === "wp-codebox.agent-fanout") { const startedAt = new Date().toISOString() const result = await executeAgentFanoutFromArgs(step.args ?? [], { @@ -288,6 +319,179 @@ export async function executeRecipeWorkflowStep(runtime: Runtime, workflowStep: } } +function recipeCommandHandlesItsOwnExecution(command: string): boolean { + return command === "wordpress.collect-workload-result" + || command === "wordpress.run-workload" + || command === "wp-codebox.agent-fanout" + || command === "wp-codebox.bounded-runtime-plan" + || command === "wp-codebox.checkpoint-create" + || command === "wp-codebox.checkpoint-restore" + || command === "wp-codebox.checkpoint-list" + || command === "wp-codebox/run-fuzz-suite" +} + +export class RecipeContinuationError extends Error { + readonly code = "recipe-continuation-failed" + + constructor(message: string, readonly continuationEvidence: RecipeContinuationEvidence) { + super(message) + this.name = "RecipeContinuationError" + } +} + +async function executeRecipeStepContinuation(runtime: Runtime, workflowStep: ReturnType[number], recipeDirectory: string, sandboxWorkspace: ReturnType | undefined, inputMountPathMap: readonly InputMountPathMapping[], onProgress?: (progress: RecipeContinuationProgress) => void): Promise { + const continuation = workflowStep.step.continuation! + let args = rewriteWorkflowStepArgs(workflowStep.step.command, workflowStep.step.args ?? [], inputMountPathMap) + const policy = continuationPolicyEvidence(continuation) + const executions: RecipeContinuationEvidence["executions"] = [] + const progress = (): RecipeContinuationProgress => ({ schema: "wp-codebox/recipe-continuation-evidence/v1", policy, iterations: executions.length, executions: [...executions] }) + onProgress?.(progress()) + const fail = (code: string, message: string): never => { + throw new RecipeContinuationError(message, { schema: "wp-codebox/recipe-continuation-evidence/v1", policy, status: "failed", iterations: executions.length, executions, diagnostics: { code, message } }) + } + + for (let iteration = 1; iteration <= continuation.maxIterations; iteration++) { + let execution: ExecutionResult | undefined + try { + const spec = await recipeExecutionSpec({ ...workflowStep.step, args }, recipeDirectory, sandboxWorkspace, { inputMountPathMap }) + execution = await runtime.execute(spec) + args = spec.resolvedArgs + } catch (error) { + fail("execution-error", error instanceof Error ? error.message : String(error)) + } + if (!execution) fail("execution-error", "Continuation command did not produce an execution result.") + const completedExecution = execution as ExecutionResult + const result = continuationResult(completedExecution) + executions.push(continuationExecutionEvidence(iteration, args, completedExecution.exitCode, result)) + onProgress?.(progress()) + if (completedExecution.exitCode !== 0) { + fail("command-failed", `Continuation command exited with code ${completedExecution.exitCode}.`) + } + const predicate = pointerValue(result, continuation.while.pointer) + if (!predicate.found) { + fail("predicate-value-missing", `Continuation predicate pointer ${continuation.while.pointer} did not resolve.`) + } + if (!isDeepStrictEqual(predicate.value, continuation.while.equals)) { + return { + ...withRecipeExecutionPhase(completedExecution, workflowStep.phase, workflowStep.index, workflowStep.step.command, recipeWorkflowArgsEvidence(workflowStep.step.args, args), workflowStep.step.metadata), + continuationEvidence: { schema: "wp-codebox/recipe-continuation-evidence/v1", policy, status: "completed", iterations: iteration, executions }, + } + } + if (iteration === continuation.maxIterations) { + throw new RecipeContinuationError(`Continuation reached its maximum of ${continuation.maxIterations} iterations without a terminal result.`, { schema: "wp-codebox/recipe-continuation-evidence/v1", policy, status: "exhausted", iterations: iteration, executions, diagnostics: { code: "max-iterations-exhausted", message: "Continuation predicate remained matched at the iteration limit." } }) + } + args = applyContinuationMappings(args, result, continuation.inputMappings, fail) + } + throw new Error("Unreachable continuation state") +} + +const CONTINUATION_RESULT_PREVIEW_MAX_BYTES = 4096 + +function continuationPolicyEvidence(continuation: NonNullable): RecipeContinuationEvidence["policy"] { + const serializedEquals = JSON.stringify(continuation.while.equals) ?? "null" + const equalsBytes = Buffer.byteLength(serializedEquals, "utf8") + return { + maxIterations: continuation.maxIterations, + while: { + pointer: continuation.while.pointer, + equalsBytes, + equalsSha256: createHash("sha256").update(serializedEquals).digest("hex"), + ...(equalsBytes <= CONTINUATION_RESULT_PREVIEW_MAX_BYTES ? { equals: continuation.while.equals } : { equalsTruncated: true }), + }, + inputMappings: continuation.inputMappings.map((mapping) => ({ from: mapping.from, to: { ...mapping.to } })), + } +} + +function continuationExecutionEvidence(iteration: number, args: string[], exitCode: number, result: unknown): RecipeContinuationEvidence["executions"][number] { + const serialized = result === undefined ? "" : JSON.stringify(result) + const resultBytes = Buffer.byteLength(serialized, "utf8") + return { + iteration, + exitCode, + argsSha256: createHash("sha256").update(JSON.stringify(args)).digest("hex"), + resultBytes, + resultSha256: createHash("sha256").update(serialized).digest("hex"), + ...(result !== undefined && resultBytes <= CONTINUATION_RESULT_PREVIEW_MAX_BYTES ? { result } : {}), + ...(resultBytes > CONTINUATION_RESULT_PREVIEW_MAX_BYTES ? { resultTruncated: true } : {}), + } +} + +function continuationResult(execution: ExecutionResult): unknown { + if (execution.result?.json !== undefined) return execution.result.json + try { + return JSON.parse(execution.stdout) + } catch { + return undefined + } +} + +function applyContinuationMappings(args: string[], result: unknown, mappings: NonNullable["inputMappings"], fail: (code: string, message: string) => never): string[] { + const next = [...args] + for (const mapping of mappings) { + const source = pointerValue(result, mapping.from) + if (!source.found) fail("mapping-source-missing", `Continuation source pointer ${mapping.from} did not resolve.`) + const indexes = next.map((argument, index) => argument.startsWith(`${mapping.to.arg}=`) ? index : -1).filter((index) => index >= 0) + if (indexes.length !== 1) fail(indexes.length === 0 ? "target-argument-missing" : "target-argument-ambiguous", `Continuation target argument ${mapping.to.arg} must appear exactly once.`) + const index = indexes[0]! + const raw = next[index]!.slice(mapping.to.arg.length + 1) + let target: unknown + try { + target = JSON.parse(raw) + } catch { + fail("target-argument-not-json", `Continuation target argument ${mapping.to.arg} must contain JSON.`) + } + if (!target || typeof target !== "object") fail("target-argument-not-container", `Continuation target argument ${mapping.to.arg} must contain a JSON object or array.`) + if (mapping.to.pointer === "") fail("target-root-mutation-unsupported", "Continuation mappings cannot replace a target argument root.") + if (!setPointerValue(target, mapping.to.pointer, source.value)) fail("target-pointer-missing", `Continuation target pointer ${mapping.to.pointer} did not resolve to an existing value.`) + next[index] = `${mapping.to.arg}=${JSON.stringify(target)}` + } + return next +} + +function pointerValue(value: unknown, pointer: string): { found: boolean; value?: unknown } { + const segments = decodeJsonPointer(pointer) + if (!segments) return { found: false } + let current = value + for (const segment of segments) { + if (Array.isArray(current)) { + if (!/^(0|[1-9][0-9]*)$/.test(segment) || Number(segment) >= current.length) return { found: false } + current = current[Number(segment)] + } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) { + current = (current as Record)[segment] + } else return { found: false } + } + return { found: true, value: current } +} + +function setPointerValue(target: object, pointer: string, value: unknown): boolean { + const segments = decodeJsonPointer(pointer) + if (!segments || segments.length === 0) return false + const parentSegments = segments.slice(0, -1) + const parent = pointerValue(target, parentSegments.length === 0 ? "" : `/${parentSegments.map(encodeJsonPointerSegment).join("/")}`) + if (!parent.found || !parent.value || typeof parent.value !== "object") return false + const key = segments.at(-1)! + if (Array.isArray(parent.value)) { + if (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= parent.value.length) return false + parent.value[Number(key)] = value + return true + } + if (["__proto__", "constructor", "prototype"].includes(key)) return false + ;(parent.value as Record)[key] = value + return true +} + +function decodeJsonPointer(pointer: string): string[] | undefined { + if (pointer === "") return [] + if (!pointer.startsWith("/")) return undefined + const segments = pointer.slice(1).split("/") + if (segments.some((segment) => /~(?:[^01]|$)/.test(segment))) return undefined + return segments.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")) +} + +function encodeJsonPointerSegment(segment: string): string { + return segment.replace(/~/g, "~0").replace(/\//g, "~1") +} + function rewriteWorkflowStepArgs(command: string, args: readonly string[], inputMountPathMap: readonly InputMountPathMapping[] = []): string[] { const rewritten = rewriteInputMountPathArgs(args, inputMountPathMap) if (command === "wordpress.run-workload") { diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 6f9283c30..1316dd308 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -33,7 +33,7 @@ import { provisionRuntimeServices, provisionRuntimeServicesForRecipe, runtimeSer import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js" import { recipeAdversarialCampaignFailure, runRecipeAdversarialCampaigns, writeRecipeAdversarialEvidence, type RecipeAdversarialCampaignOutput } from "../adversarial-recipe.js" import { classifyRuntimeMemoryFailure, replayWithHostNodeHeap } from "../host-node-heap.js" -import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeDiagnosticArtifactRef, RecipeEffectiveRecipeArtifact, RecipeExecutionResult, RecipeFuzzCaseCommandRef, RecipeFuzzCaseResult, RecipeFuzzCaseStatus, RecipeFuzzRunResult, RecipeInterruptionController, RecipePhaseEvidence, RecipePhaseName, RecipePhpWasmRuntimeDiagnostic, RecipeRunCommandOutput, RecipeRunComponentContract, RecipeRunDeclaredArtifact, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunFixtureDatabase, RecipeRunOptions, RecipeRunOutput, RecipeRunPreparedExtraPlugin, RecipeRunProbe, RecipeRunProvenance, RecipeRunStagedFile, RecipeRuntimeDiagnostic, RecipeStepFailure, RecipeValidateOptions, RecipeValidateOutput } from "./recipe-run-types.js" +import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeContinuationProgress, RecipeDiagnosticArtifactRef, RecipeEffectiveRecipeArtifact, RecipeExecutionResult, RecipeFuzzCaseCommandRef, RecipeFuzzCaseResult, RecipeFuzzCaseStatus, RecipeFuzzRunResult, RecipeInterruptionController, RecipePhaseEvidence, RecipePhaseName, RecipePhpWasmRuntimeDiagnostic, RecipeRunCommandOutput, RecipeRunComponentContract, RecipeRunDeclaredArtifact, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunFixtureDatabase, RecipeRunOptions, RecipeRunOutput, RecipeRunPreparedExtraPlugin, RecipeRunProbe, RecipeRunProvenance, RecipeRunStagedFile, RecipeRuntimeDiagnostic, RecipeStepFailure, RecipeValidateOptions, RecipeValidateOutput } from "./recipe-run-types.js" const DEFAULT_RECIPE_RUN_TIMEOUT_MS = 25 * 60 * 1000 const SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS = 120 * 1000 @@ -318,20 +318,21 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe for (const workflowStep of workflowSteps) { const operation = `workflow.${workflowStep.phase}[${workflowStep.index}]:${workflowStep.step.command}` const stepStartedAtMs = Date.now() + let continuationProgress: RecipeContinuationProgress | undefined try { const execution = await awaitRecipe(operation, async () => workflowStep.step.command === "wordpress.collect-workload-result" ? withRecipeExecutionPhase(executeRecipeCollectWorkloadResult(workflowStep.step, executions, new Date().toISOString()), workflowStep.phase, workflowStep.index, workflowStep.step.command, recipeWorkflowArgsEvidence(workflowStep.step.args, workflowStep.step.args), workflowStep.step.metadata) - : executeRecipeWorkflowStep(runtime!, workflowStep, recipeDirectory, sandboxWorkspace, configuredArtifactsDirectory, options, inputMountPathMap), workflowStep.step.timeoutMs) + : executeRecipeWorkflowStep(runtime!, workflowStep, recipeDirectory, sandboxWorkspace, configuredArtifactsDirectory, options, inputMountPathMap, (progress) => { continuationProgress = progress }), workflowStep.step.timeoutMs) executions.push({ ...execution, ...(recipeWorkflowStepIsAdvisory(workflowStep.step) ? { recipeAdvisory: true } : {}) }) interruption?.throwIfInterrupted() } catch (error) { - const failure = recipeStepFailure(workflowStep, error, stepStartedAtMs) + const failure = recipeStepFailure(workflowStep, error, stepStartedAtMs, Date.now(), continuationProgress) stepFailures.push(failure) await artifactPointer.update({ command: operation, commandStatus: "failed", failure: failure.error, phases: phaseTracker.list(), stepFailures }) if (!recipeWorkflowStepIsAdvisory(workflowStep.step)) { throw error } - advisoryFailures.push(recipeAdvisoryFailure(workflowStep, error)) + advisoryFailures.push(recipeAdvisoryFailure(workflowStep, error, continuationProgress)) interruption?.clear() } } @@ -1349,7 +1350,7 @@ function pluginFileFromActivationFailure(message: string, phaseData: Record; steps: Array<{ command: string; args: string[] }>; after?: Array<{ command: string; args: string[] }> } { +function recipeWorkflowMetadata(recipe: WorkspaceRecipe): { before?: Array>; steps: Array>; after?: Array> } { return { ...(recipe.workflow.before ? { before: recipe.workflow.before.map(recipeStepMetadata) } : {}), steps: recipe.workflow.steps.map(recipeStepMetadata), @@ -1368,7 +1369,7 @@ function effectiveRecipeWorkflowMetadata(recipe: WorkspaceRecipe, inputMountPath function effectiveRecipeStepMetadata(step: WorkspaceRecipe["workflow"]["steps"][number], inputMountPathMap: NonNullable>["inputMountPathMap"]>): Record { const original = step.args ?? [] const effective = rewriteInputMountPathArgsForEvidence(original, inputMountPathMap) - return stripUndefined({ command: step.command, args: effective, originalArgs: original, effectiveArgs: effective, argsRewritten: JSON.stringify(original) !== JSON.stringify(effective) }) + return stripUndefined({ command: step.command, args: effective, originalArgs: original, effectiveArgs: effective, argsRewritten: JSON.stringify(original) !== JSON.stringify(effective), continuation: step.continuation }) } function effectiveRecipeForReplay(recipe: WorkspaceRecipe, inputMountPathMap: NonNullable>["inputMountPathMap"]>): WorkspaceRecipe { @@ -1466,8 +1467,8 @@ function packageDependencyVersion(manifest: Record, name: strin ?? stringValue(recordValue(manifest.peerDependencies)?.[name]) } -function recipeStepMetadata(step: WorkspaceRecipe["workflow"]["steps"][number]): { command: string; args: string[] } { - return { command: step.command, args: step.args ?? [] } +function recipeStepMetadata(step: WorkspaceRecipe["workflow"]["steps"][number]): { command: string; args: string[]; continuation?: WorkspaceRecipe["workflow"]["steps"][number]["continuation"] } { + return stripUndefined({ command: step.command, args: step.args ?? [], continuation: step.continuation }) as { command: string; args: string[]; continuation?: WorkspaceRecipe["workflow"]["steps"][number]["continuation"] } } function effectiveRecipePreview(recipePreview: RuntimePreviewSpec | undefined, options: RecipeRunOptions): RuntimePreviewSpec { diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index 6dfedd149..adffed307 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -268,6 +268,7 @@ interface RecipeDryRunStep { resolvedCommand: string resolvedArgs: string[] resolvedParsedArgs: Record + continuation?: WorkspaceRecipe["workflow"]["steps"][number]["continuation"] policy: { status: "allowed" | "denied" command: string @@ -656,6 +657,7 @@ async function recipeDryRunStep(step: WorkspaceRecipe["workflow"]["steps"][numbe resolvedCommand: resolved.command, resolvedArgs: resolved.args, resolvedParsedArgs: parseRecipeArgs(resolved.args), + ...(step.continuation ? { continuation: step.continuation } : {}), policy: { status: allowed ? "allowed" : "denied", command: resolved.command, diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 72074cbf5..ca11e590f 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -1222,6 +1222,28 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche type: "object", additionalProperties: false, required: ["command"], + allOf: [ + { + if: { + properties: { + command: { + enum: [ + "wordpress.collect-workload-result", + "wordpress.run-workload", + "wp-codebox.agent-fanout", + "wp-codebox.bounded-runtime-plan", + "wp-codebox.checkpoint-create", + "wp-codebox.checkpoint-restore", + "wp-codebox.checkpoint-list", + "wp-codebox/run-fuzz-suite", + ], + }, + }, + required: ["command"], + }, + then: { not: { required: ["continuation"] } }, + }, + ], properties: { command: commandSchema, args: { @@ -1229,12 +1251,57 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche items: { type: "string" }, }, timeoutMs: { type: "integer", minimum: 1 }, + continuation: { $ref: "#/$defs/stepContinuation" }, diagnostics: { $ref: "#/$defs/commandDiagnosticsCapture" }, metadata: { $ref: "#/$defs/metadata" }, allowFailure: { type: "boolean" }, advisory: { type: "boolean" }, }, }, + stepContinuation: { + type: "object", + additionalProperties: false, + required: ["maxIterations", "while", "inputMappings"], + properties: { + maxIterations: { type: "integer", minimum: 2, maximum: 1000 }, + while: { + type: "object", + additionalProperties: false, + required: ["pointer", "equals"], + properties: { + pointer: { $ref: "#/$defs/jsonPointer" }, + equals: {}, + }, + }, + inputMappings: { + type: "array", + minItems: 1, + maxItems: 64, + items: { + type: "object", + additionalProperties: false, + required: ["from", "to"], + properties: { + from: { $ref: "#/$defs/jsonPointer" }, + to: { + type: "object", + additionalProperties: false, + required: ["arg", "pointer"], + properties: { + arg: { type: "string", minLength: 1, maxLength: 256 }, + pointer: { $ref: "#/$defs/jsonPointer" }, + }, + }, + }, + }, + }, + }, + }, + jsonPointer: { + type: "string", + maxLength: 1024, + pattern: "^(|/(?:[^~/]|~[01])*)*$", + }, commandDiagnosticsCapture: { type: "object", additionalProperties: false, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 67fea4031..b8d7d471e 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -293,12 +293,32 @@ export interface WorkspaceRecipeStep { command: string args?: string[] timeoutMs?: number + continuation?: WorkspaceRecipeStepContinuation diagnostics?: RuntimeCommandDiagnosticsCaptureSpec metadata?: Record allowFailure?: boolean advisory?: boolean } +export interface WorkspaceRecipeStepContinuation { + maxIterations: number + while: WorkspaceRecipeStepContinuationPredicate + inputMappings: WorkspaceRecipeStepContinuationInputMapping[] +} + +export interface WorkspaceRecipeStepContinuationPredicate { + pointer: string + equals: unknown +} + +export interface WorkspaceRecipeStepContinuationInputMapping { + from: string + to: { + arg: string + pointer: string + } +} + export interface RuntimeCommandDiagnosticsCaptureSpec { capture?: RuntimeCommandDiagnosticsCaptureKind[] maxItems?: number diff --git a/tests/recipe-step-continuation.integration.test.ts b/tests/recipe-step-continuation.integration.test.ts new file mode 100644 index 000000000..46f6e7ed8 --- /dev/null +++ b/tests/recipe-step-continuation.integration.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { promisify } from "node:util" + +import { withTempDir } from "../scripts/test-kit.js" + +const execFileAsync = promisify(execFile) + +await withTempDir("wp-codebox-recipe-continuation-integration-", async (root) => { + const pluginDirectory = join(root, "continuation-fixture") + const recipePath = join(root, "recipe.json") + const timeoutRecipePath = join(root, "timeout-recipe.json") + const artifactsPath = join(root, "artifacts") + const timeoutArtifactsPath = join(root, "timeout-artifacts") + await mkdir(pluginDirectory) + await writeFile(join(pluginDirectory, "continuation-fixture.php"), ` 'Example', + 'description' => 'Abilities used by WP Codebox integration tests.', + ) ); +} ); +add_action( 'wp_abilities_api_init', static function (): void { + wp_register_ability( 'example/continue', array( + 'label' => 'Continue', + 'description' => 'Deterministic continuation fixture.', + 'category' => 'example', + 'input_schema' => array( 'type' => 'object' ), + 'output_schema' => array( 'type' => 'object' ), + 'permission_callback' => '__return_true', + 'execute_callback' => static function ( array $input ): array { + $receipt = (string) ( $input['receipt'] ?? '' ); + if ( ! empty( $input['delay'] ) && 'opaque-1' === $receipt ) { + usleep( 5000000 ); + } + $next = array( 'initial' => 'opaque-1', 'opaque-1' => 'opaque-2', 'opaque-2' => 'terminal' ); + if ( ! isset( $next[ $receipt ] ) ) { + return array( 'more' => false, 'receipt' => 'invalid', 'path' => array( $receipt ) ); + } + return array( 'more' => 'opaque-2' !== $receipt, 'receipt' => $next[ $receipt ], 'path' => array( $receipt, $next[ $receipt ] ) ); + }, + ) ); +} ); +`) + await writeFile(recipePath, `${JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backend: "wordpress-playground", wp: "latest", blueprint: { steps: [] } }, + inputs: { extra_plugins: [{ source: pluginDirectory, slug: "continuation-fixture", activate: true }] }, + workflow: { + steps: [{ + command: "wordpress.ability", + args: ["name=example/continue", "input={\"receipt\":\"initial\"}"], + continuation: { + maxIterations: 3, + while: { pointer: "/result/more", equals: true }, + inputMappings: [{ from: "/result/receipt", to: { arg: "input", pointer: "/receipt" } }], + }, + }], + }, + })}\n`) + + const command = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 4 * 1024 * 1024, + }) + const output = JSON.parse(command.stdout) + assert.equal(output.success, true, command.stdout) + const execution = output.executions.find((candidate: { command?: string }) => candidate.command === "wordpress.ability") + assert.equal(execution.continuationEvidence.status, "completed") + assert.equal(execution.continuationEvidence.iterations, 3) + assert.deepEqual(execution.continuationEvidence.executions.map((iteration: { result: { result: { receipt: string } } }) => iteration.result.result.receipt), ["opaque-1", "opaque-2", "terminal"]) + + const commands = (await readFile(output.artifacts.commandsPath, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) + const abilityCalls = commands.filter((candidate) => candidate.command === "wordpress.ability") + assert.equal(abilityCalls.length, 3) + + await writeFile(timeoutRecipePath, `${JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + runtime: { backend: "wordpress-playground", wp: "latest", blueprint: { steps: [] } }, + inputs: { extra_plugins: [{ source: pluginDirectory, slug: "continuation-fixture", activate: true }] }, + workflow: { + steps: [{ + command: "wordpress.ability", + args: ["name=example/continue", "input={\"receipt\":\"initial\",\"delay\":true}"], + timeoutMs: 2500, + continuation: { + maxIterations: 3, + while: { pointer: "/result/more", equals: true }, + inputMappings: [{ from: "/result/receipt", to: { arg: "input", pointer: "/receipt" } }], + }, + }], + }, + })}\n`) + + let timeoutOutput: Record | undefined + try { + await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", timeoutRecipePath, "--artifacts", timeoutArtifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 4 * 1024 * 1024, + }) + assert.fail("Expected continuation recipe to time out") + } catch (error) { + const stdout = (error as { stdout?: string }).stdout + assert.equal(typeof stdout, "string") + timeoutOutput = JSON.parse(stdout!) + } + assert.equal(timeoutOutput.success, false) + assert.equal(timeoutOutput.stepFailures[0].classification, "timeout") + assert.equal(timeoutOutput.stepFailures[0].continuationEvidence.status, "failed") + assert.equal(timeoutOutput.stepFailures[0].continuationEvidence.iterations, 1) + assert.equal(timeoutOutput.stepFailures[0].continuationEvidence.diagnostics.code, "step-timeout") +}) + +console.log("recipe step continuation integration passed") diff --git a/tests/recipe-step-continuation.test.ts b/tests/recipe-step-continuation.test.ts new file mode 100644 index 000000000..a2692a524 --- /dev/null +++ b/tests/recipe-step-continuation.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict" + +import { validateWorkspaceRecipeJsonSchema, type ExecutionResult, type Runtime, type WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { RecipeContinuationError, executeRecipeWorkflowStep, recipeStepFailure } from "../packages/cli/src/commands/recipe-run-workflow-evidence.js" + +class FakeRuntime { + readonly calls: Array<{ args: string[] }> = [] + + constructor(private readonly executeResult: (args: string[], iteration: number) => ExecutionResult) {} + + async execute(spec: { args?: string[] }): Promise { + const args = [...(spec.args ?? [])] + this.calls.push({ args }) + return this.executeResult(args, this.calls.length) + } +} + +const resumableStep: WorkspaceRecipe["workflow"]["steps"][number] = { + command: "example/resumable", + args: ["name=example", "input={\"cursor\":\"initial\",\"nested\":{\"a/b\":[{\"~key\":null}]}}", "mode=preserved"], + continuation: { + maxIterations: 5, + while: { pointer: "/result/more", equals: true }, + inputMappings: [ + { from: "/result/cursor", to: { arg: "input", pointer: "/cursor" } }, + { from: "/result/a~1b/0/~0key", to: { arg: "input", pointer: "/nested/a~1b/0/~0key" } }, + { from: "/result/newToken", to: { arg: "input", pointer: "/nextToken" } }, + ], + }, +} + +const runtime = new FakeRuntime((args, iteration) => ({ + command: "example/resumable", + args, + exitCode: 0, + stdout: "not-json", + stderr: "", + result: { json: { result: { more: iteration < 3, cursor: `opaque-${iteration}`, newToken: `new-${iteration}`, "a/b": [{ "~key": { iteration } }] } } }, +})) +const execution = await executeRecipeWorkflowStep(runtime as unknown as Runtime, { phase: "steps", index: 0, step: resumableStep }, process.cwd()) +assert.equal(runtime.calls.length, 3) +assert.deepEqual(runtime.calls.map(({ args }) => JSON.parse(args.find((arg) => arg.startsWith("input="))!.slice(6))), [ + { cursor: "initial", nested: { "a/b": [{ "~key": null }] } }, + { cursor: "opaque-1", nested: { "a/b": [{ "~key": { iteration: 1 } }] }, nextToken: "new-1" }, + { cursor: "opaque-2", nested: { "a/b": [{ "~key": { iteration: 2 } }] }, nextToken: "new-2" }, +]) +assert.ok(runtime.calls.every(({ args }) => args.includes("mode=preserved")), "unmapped arguments remain unchanged") +assert.equal(execution.result?.json && (execution.result.json as { result: { more: boolean } }).result.more, false) +assert.equal(execution.continuationEvidence?.status, "completed") +assert.equal(execution.continuationEvidence?.iterations, 3) +assert.equal(execution.continuationEvidence?.executions.length, 3) +assert.deepEqual(execution.continuationEvidence?.executions.map(({ iteration, exitCode }) => ({ iteration, exitCode })), [{ iteration: 1, exitCode: 0 }, { iteration: 2, exitCode: 0 }, { iteration: 3, exitCode: 0 }]) +assert.ok(execution.continuationEvidence?.executions.every(({ argsSha256, resultSha256 }) => /^[a-f0-9]{64}$/.test(argsSha256) && /^[a-f0-9]{64}$/.test(resultSha256))) +assert.equal(execution.continuationEvidence?.executions.every(({ resultTruncated }) => resultTruncated === undefined), true) +assert.equal(execution.continuationEvidence?.policy.while.equals, true) +assert.equal(execution.continuationEvidence?.policy.while.equalsBytes, 4) +assert.match(execution.continuationEvidence?.policy.while.equalsSha256 ?? "", /^[a-f0-9]{64}$/) + +const largeResultRuntime = new FakeRuntime((args, iteration) => ({ + command: "example/resumable", + args, + exitCode: 0, + stdout: "", + stderr: "", + result: { json: { result: { more: iteration < 2, cursor: `opaque-${iteration}`, newToken: `new-${iteration}`, "a/b": [{ "~key": {} }], payload: "x".repeat(8192) } } }, +})) +const largeResultExecution = await executeRecipeWorkflowStep(largeResultRuntime as unknown as Runtime, { phase: "steps", index: 0, step: resumableStep }, process.cwd()) +assert.equal(largeResultExecution.continuationEvidence?.executions[0]?.result, undefined) +assert.equal(largeResultExecution.continuationEvidence?.executions[0]?.resultTruncated, true) +assert.ok((largeResultExecution.continuationEvidence?.executions[0]?.resultBytes ?? 0) > 4096) + +async function continuationFailure(step: WorkspaceRecipe["workflow"]["steps"][number], executionResult: ExecutionResult, code: string): Promise { + await assert.rejects( + () => executeRecipeWorkflowStep(new FakeRuntime(() => executionResult) as unknown as Runtime, { phase: "steps", index: 0, step }, process.cwd()), + (error: unknown) => { + const cause = error instanceof Error ? error.cause : undefined + assert.ok(cause instanceof RecipeContinuationError) + assert.equal(cause.code, "recipe-continuation-failed") + assert.equal(cause.continuationEvidence.diagnostics?.code, code) + assert.ok(cause.continuationEvidence.iterations <= step.continuation!.maxIterations) + assert.equal(recipeStepFailure({ phase: "steps", index: 0, step }, error, Date.now()).continuationEvidence?.diagnostics?.code, code) + return true + }, + ) +} + +const continuing = (json: unknown, exitCode = 0): ExecutionResult => ({ command: "example/resumable", args: [], exitCode, stdout: JSON.stringify(json), stderr: "", result: exitCode === 0 ? undefined : { json } }) +await continuationFailure({ ...resumableStep, continuation: { ...resumableStep.continuation!, while: { pointer: "/result/~2bad", equals: true } } }, continuing({ result: { more: true } }), "predicate-value-missing") +await continuationFailure({ ...resumableStep, args: ["input=not-json"], continuation: { ...resumableStep.continuation!, inputMappings: [{ from: "/result/cursor", to: { arg: "input", pointer: "/cursor" } }] } }, continuing({ result: { more: true, cursor: "next" } }), "target-argument-not-json") +await continuationFailure({ ...resumableStep, continuation: { ...resumableStep.continuation!, inputMappings: [{ from: "/result/missing", to: { arg: "input", pointer: "/cursor" } }] } }, continuing({ result: { more: true } }), "mapping-source-missing") +await continuationFailure({ ...resumableStep, continuation: { ...resumableStep.continuation!, inputMappings: [{ from: "/result/cursor", to: { arg: "missing", pointer: "/cursor" } }] } }, continuing({ result: { more: true, cursor: "next" } }), "target-argument-missing") +await continuationFailure({ ...resumableStep, continuation: { ...resumableStep.continuation!, inputMappings: [{ from: "/result/cursor", to: { arg: "input", pointer: "/__proto__" } }] } }, continuing({ result: { more: true, cursor: "next" } }), "target-pointer-missing") +await continuationFailure({ ...resumableStep, continuation: { ...resumableStep.continuation!, maxIterations: 2 } }, continuing({ result: { more: true, cursor: "next", newToken: "new", "a/b": [{ "~key": {} }] } }), "max-iterations-exhausted") +await continuationFailure(resumableStep, continuing({ result: { more: true } }, 1), "command-failed") + +const schemaRecipe: WorkspaceRecipe = { schema: "wp-codebox/workspace-recipe/v1", workflow: { steps: [resumableStep] } } +assert.equal(validateWorkspaceRecipeJsonSchema(schemaRecipe).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...schemaRecipe, workflow: { steps: [{ ...resumableStep, continuation: { ...resumableStep.continuation!, maxIterations: 1 } }] } }).valid, false) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...schemaRecipe, workflow: { steps: [{ ...resumableStep, continuation: { ...resumableStep.continuation!, inputMappings: [] } }] } }).valid, false) +assert.equal(validateWorkspaceRecipeJsonSchema({ ...schemaRecipe, workflow: { steps: [{ ...resumableStep, continuation: { ...resumableStep.continuation!, while: { pointer: "/bad~2pointer", equals: true } } }] } }).valid, false) +for (const command of [ + "wordpress.collect-workload-result", + "wordpress.run-workload", + "wp-codebox.agent-fanout", + "wp-codebox.bounded-runtime-plan", + "wp-codebox.checkpoint-create", + "wp-codebox.checkpoint-restore", + "wp-codebox.checkpoint-list", + "wp-codebox/run-fuzz-suite", +]) { + assert.equal(validateWorkspaceRecipeJsonSchema({ ...schemaRecipe, workflow: { steps: [{ ...resumableStep, command }] } }).valid, false, command) +} +assert.equal(validateWorkspaceRecipeJsonSchema({ ...schemaRecipe, workflow: { steps: [{ ...resumableStep, continuation: { ...resumableStep.continuation!, inputMappings: Array.from({ length: 65 }, () => resumableStep.continuation!.inputMappings[0]!) } }] } }).valid, false) + +await assert.rejects( + () => executeRecipeWorkflowStep(runtime as unknown as Runtime, { phase: "steps", index: 0, step: { ...resumableStep, command: "wp-codebox.agent-fanout" } }, process.cwd()), + /Continuation is unavailable for recipe command wp-codebox\.agent-fanout/, +) + +const largePolicyRuntime = new FakeRuntime((args) => ({ + command: "example/resumable", + args, + exitCode: 0, + stdout: "", + stderr: "", + result: { json: { result: { more: false } } }, +})) +const largePolicyExecution = await executeRecipeWorkflowStep(largePolicyRuntime as unknown as Runtime, { + phase: "steps", + index: 0, + step: { ...resumableStep, continuation: { ...resumableStep.continuation!, while: { pointer: "/result/more", equals: "x".repeat(8192) } } }, +}, process.cwd()) +assert.equal(largePolicyExecution.continuationEvidence?.policy.while.equals, undefined) +assert.equal(largePolicyExecution.continuationEvidence?.policy.while.equalsTruncated, true) +assert.equal(largePolicyExecution.continuationEvidence?.policy.while.equalsBytes, 8194) + +console.log("recipe step continuation contract ok")