diff --git a/packages/cli/src/commands/agent-task-run.ts b/packages/cli/src/commands/agent-task-run.ts index a397ad84..e10e6007 100644 --- a/packages/cli/src/commands/agent-task-run.ts +++ b/packages/cli/src/commands/agent-task-run.ts @@ -1,9 +1,11 @@ import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { dirname, isAbsolute, join, relative, resolve } from "node:path" -import { AGENT_TASK_RUN_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, artifactResultEnvelope, buildAgentTaskRecipe, DEFAULT_WORDPRESS_VERSION, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeTaskInput, parseCommandJson, parseCommandOptions, publicArtifactRefGroups, resolveEffectiveRuntimeToolPolicy, type AgentTaskRunInput, type AgentTaskRunResultSummary, type AgentTerminalResult, type ArtifactResultEnvelope, type HeadlessAgentTaskResult, type SandboxToolPolicySnapshot, type TypedArtifactDTO } from "@automattic/wp-codebox-core" +import { AGENT_TASK_RUN_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, artifactResultEnvelope, buildAgentTaskRecipe, DEFAULT_WORDPRESS_VERSION, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeTaskInput, parseCommandOptions, publicArtifactRefGroups, resolveEffectiveRuntimeToolPolicy, type AgentTaskRunInput, type AgentTaskRunResultSummary, type AgentTerminalResult, type ArtifactResultEnvelope, type HeadlessAgentTaskResult, type SandboxToolPolicySnapshot, type TypedArtifactDTO } from "@automattic/wp-codebox-core" import { stripUndefined } from "@automattic/wp-codebox-core/internals" -import { runRecipeRunCommand } from "./recipe-run.js" +import { parsePreviewBind, parsePreviewHoldSeconds, parsePreviewLease, parsePreviewPort, parsePreviewPublicUrl } from "../preview-options.js" +import { createRecipeRunOptions, executeRecipeRun } from "./recipe-run.js" +import type { RecipeRunCommandOutput } from "./recipe-run-types.js" export type { AgentTaskRunInput } from "@automattic/wp-codebox-core" @@ -48,13 +50,6 @@ export interface AgentTaskRunOutput { metadata: Record } -interface CapturedOutput { - result: T - stdout: string - stderr: string - exitCode: number -} - export interface FailureEvidenceInput { input: AgentTaskRunInput task: string @@ -63,7 +58,6 @@ export interface FailureEvidenceInput { recipePath: string generatedRecipeArtifact?: GeneratedRecipeArtifactRef run: Record - capture?: CapturedOutput error?: unknown } @@ -130,38 +124,29 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR const task = taskInput.goal const wpVersion = stringValue(input.wp) || DEFAULT_WORDPRESS_VERSION const artifacts = stringValue(input.artifacts_path) || await mkdtemp(join(tmpdir(), "wp-codebox-agent-task-artifacts-")) - const recipeDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-agent-task-recipe-")) - const recipePath = join(recipeDirectory, "recipe.json") - let capture: CapturedOutput | undefined + const recipePath = join(artifacts, "files", "generated-recipe", "recipe.json") + let runOutput: RecipeRunCommandOutput | undefined let recipeJson = "" let generatedRecipeArtifact: GeneratedRecipeArtifactRef | undefined try { const recipe = buildAgentTaskRecipe({ ...input, artifacts_path: artifacts }, taskInput, wpVersion) recipeJson = `${JSON.stringify(recipe, null, 2)}\n` - await writeFile(recipePath, recipeJson) - const recipeRunArgs = ["--recipe", recipePath, "--artifacts", artifacts, "--json"] - if (options.previewHoldSeconds) { - recipeRunArgs.push("--preview-hold-seconds", options.previewHoldSeconds) - } - if (options.previewPort) { - recipeRunArgs.push("--preview-port", options.previewPort) - } - if (options.previewBind) { - recipeRunArgs.push("--preview-bind", options.previewBind) - } - if (options.previewHoldBlocking) { - recipeRunArgs.push("--preview-hold-blocking") - } - if (options.previewPublicUrl) { - recipeRunArgs.push("--preview-public-url", options.previewPublicUrl) - } - if (options.previewLeaseJson) { - recipeRunArgs.push("--preview-lease-json", options.previewLeaseJson) - } - capture = await captureOutput(() => runRecipeRunCommand(recipeRunArgs)) + runOutput = await executeRecipeRun(createRecipeRunOptions({ + recipePath, + recipe, + recipeDirectory: process.cwd(), + artifactsDirectory: artifacts, + previewHoldSeconds: options.previewHoldSeconds ? parsePreviewHoldSeconds(options.previewHoldSeconds) : undefined, + previewPort: options.previewPort ? parsePreviewPort(options.previewPort) : undefined, + previewBind: options.previewBind ? parsePreviewBind(options.previewBind) : undefined, + previewHoldBlocking: options.previewHoldBlocking ?? false, + previewPublicUrl: options.previewPublicUrl ? parsePreviewPublicUrl(options.previewPublicUrl) : undefined, + previewLease: options.previewLeaseJson ? parsePreviewLease(options.previewLeaseJson) : undefined, + })) generatedRecipeArtifact = await persistGeneratedRecipeArtifact(artifacts, recipeJson) - const run = parseRecipeRunOutput(capture.stdout) + const run = runOutput as unknown as Record + const exitCode = runOutput.success ? 0 : 1 const runRecord = objectValue(run.run) || {} const artifactsRecord = objectValue(run.artifacts) || {} const runtimeRecord = objectValue(run.runtime) || {} @@ -196,10 +181,10 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR orchestrator: input.orchestrator, parent_request_schema: stringValue(input.parent_request?.schema), }), - }, { exitStatus: capture.exitCode }) + }, { exitStatus: exitCode }) const success = normalizedRunResult.success - const failureEvidence = success ? undefined : buildFailureEvidence({ input, task, wpVersion, artifacts, recipePath, generatedRecipeArtifact, run, capture }) - const outputDiagnostics = [...diagnostics(run, success ? 0 : capture.exitCode, success, failureEvidence), ...(hasAgentBundle ? workload.diagnostics.map((diagnostic) => ({ ...diagnostic })) : [])] + const failureEvidence = success ? undefined : buildFailureEvidence({ input, task, wpVersion, artifacts, recipePath, generatedRecipeArtifact, run }) + const outputDiagnostics = [...diagnostics(run, success ? 0 : exitCode, success, failureEvidence), ...(hasAgentBundle ? workload.diagnostics.map((diagnostic) => ({ ...diagnostic })) : [])] const agentTaskRunResult = success ? normalizedRunResult : withFailureEvidence(normalizedRunResult, failureEvidence, outputDiagnostics) const headlessAgentTaskResult = maybeHeadlessAgentTaskResult(input, agentTaskRunResult) const session = sandboxSession(input, run, artifacts, success ? "completed" : "failed") @@ -268,11 +253,11 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR } return output } catch (error) { - const run = { success: false, error: serializeUnknownError(error) } + const run = (runOutput as unknown as Record | undefined) ?? { success: false, error: serializeUnknownError(error) } generatedRecipeArtifact = recipeJson ? await persistGeneratedRecipeArtifact(artifacts, recipeJson) : undefined - const normalizedRunResult = normalizeAgentTaskRunResult(run, { exitStatus: capture?.exitCode ?? 1 }) - const failureEvidence = buildFailureEvidence({ input, task, wpVersion, artifacts, recipePath, generatedRecipeArtifact, run, capture, error }) - const failureDiagnostics = diagnostics(run, capture?.exitCode ?? 1, false, failureEvidence) + const normalizedRunResult = normalizeAgentTaskRunResult(run, { exitStatus: 1 }) + const failureEvidence = buildFailureEvidence({ input, task, wpVersion, artifacts, recipePath, generatedRecipeArtifact, run, error }) + const failureDiagnostics = diagnostics(run, 1, false, failureEvidence) const agentTaskRunResult = withFailureEvidence(normalizedRunResult, failureEvidence, failureDiagnostics) const headlessAgentTaskResult = maybeHeadlessAgentTaskResult(input, agentTaskRunResult) const session = sandboxSession(input, run, artifacts, "failed") @@ -333,8 +318,6 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR artifact_result: artifactResult, }, } - } finally { - await rm(recipeDirectory, { recursive: true, force: true }) } } @@ -450,47 +433,6 @@ function objectRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined } -async function captureOutput(callback: () => Promise): Promise> { - const originalWrite = process.stdout.write.bind(process.stdout) - const originalErrorWrite = process.stderr.write.bind(process.stderr) - let stdout = "" - let stderr = "" - ;(process.stdout.write as typeof process.stdout.write) = ((chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => { - stdout += typeof chunk === "string" ? chunk : chunk.toString() - callWriteCallback(encodingOrCallback, callback) - return true - }) as typeof process.stdout.write - ;(process.stderr.write as typeof process.stderr.write) = ((chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => { - stderr += typeof chunk === "string" ? chunk : chunk.toString() - callWriteCallback(encodingOrCallback, callback) - return true - }) as typeof process.stderr.write - try { - const result = await callback() - return { result, stdout, stderr, exitCode: Number(result) || 0 } - } finally { - process.stdout.write = originalWrite - process.stderr.write = originalErrorWrite - } -} - -function callWriteCallback(encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): void { - if (typeof encodingOrCallback === "function") { - encodingOrCallback() - } else if (callback) { - callback() - } -} - -function parseRecipeRunOutput(stdout: string): Record { - const trimmed = stdout.trim() - if (!trimmed) { - return { success: false, error: { message: "WP Codebox recipe run returned no JSON output." } } - } - const parsed = parseCommandJson(trimmed, "WP Codebox recipe run output") - return objectValue(parsed) || { success: false, error: { message: "WP Codebox recipe run returned a non-object JSON value." } } -} - async function persistGeneratedRecipeArtifact(artifacts: string, contents: string): Promise { const path = "files/generated-recipe/recipe.json" const absolutePath = join(artifacts, path) @@ -576,8 +518,8 @@ export function buildFailureEvidence(values: FailureEvidenceInput): Record typeof log === "string").join("") : "") + const stderr = stringValue(execution?.stderr) || stringValue(errorRecord.message) || "" const recipeRunEvidence = stripUndefined({ schema: stringValue(values.run.schema) || undefined, recipe_path: values.generatedRecipeArtifact?.path ?? values.recipePath, @@ -598,7 +540,7 @@ export function buildFailureEvidence(values: FailureEvidenceInput): Record { const recipePath = resolve(options.recipePath) - const recipeDirectory = dirname(recipePath) - const recipe = await loadWorkspaceRecipe(recipePath) + const recipeDirectory = resolve(options.recipeDirectory ?? dirname(recipePath)) + const recipe = options.recipe ?? await loadWorkspaceRecipe(recipePath) const configuredArtifactsDirectory = options.artifactsDirectory ?? recipe.artifacts?.directory const runRegistry = new RuntimeRunRegistry(options.runRegistryDirectory ?? defaultRunRegistryDirectory(configuredArtifactsDirectory)) const startedAtMs = Date.now() diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index 62404100..a78f2801 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -10,6 +10,8 @@ import type { RecipeSourceProvenance } from "../recipe-sources.js" export interface RecipeRunOptions { recipePath: string + recipe?: WorkspaceRecipe + recipeDirectory?: string outputPath?: string artifactsDirectory?: string runRegistryDirectory?: string @@ -33,6 +35,8 @@ export interface RecipeRunOptions { hostNodeHeapMiB?: number } +export type RecipeRunOptionsInput = Pick & Partial> + export interface RecipeValidateOptions { recipePath: string policy?: RuntimePolicy @@ -108,7 +112,7 @@ export interface RecipeRunProvenance { } } -export type RecipeRunCommandOutput = RecipeRunOutput | RecipeDryRunOutput +export type RecipeRunCommandOutput = (RecipeRunOutput | RecipeDryRunOutput) & { logs?: string[] } export interface RecipeRunComponentContract { schema: "wp-codebox/component-contract-result/v1" diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 5d5c394b..c1e6f4da 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -35,7 +35,7 @@ import { executeSmtpSinkRecipeOperation, isSmtpSinkRecipeOperation } from "../sm import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, materializeCollectedWorkloadResult, 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, 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" +import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeContinuationProgress, RecipeDiagnosticArtifactRef, RecipeEffectiveRecipeArtifact, RecipeExecutionResult, RecipeFuzzCaseCommandRef, RecipeFuzzCaseResult, RecipeFuzzCaseStatus, RecipeFuzzRunResult, RecipeInterruptionController, RecipePhaseEvidence, RecipePhaseName, RecipePhpWasmRuntimeDiagnostic, RecipeRunCommandOutput, RecipeRunComponentContract, RecipeRunDeclaredArtifact, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunFixtureDatabase, RecipeRunOptions, RecipeRunOptionsInput, 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 @@ -49,14 +49,12 @@ export async function runRecipeRunCommand(args: string[]): Promise { } const interruption = options.dryRun ? undefined : createRecipeInterruptionController() interruption?.install() - const execute = (): Promise => options.dryRun ? dryRunRecipe(options, { defaultWordPressVersion: DEFAULT_WORDPRESS_VERSION, resolveExecutionSpec: recipeExecutionSpec }) : runRecipe(options, interruption) const outputHeartbeat = options.outputPath && options.json ? setInterval(() => process.stderr.write("WP Codebox recipe-run active\n"), 30_000) : undefined outputHeartbeat?.unref() try { if (options.summary) { - const { result } = await captureStdout(execute) - const output = interruptedRecipeOutput(result, interruption) + const { result: output } = await captureStdout(() => executeRecipeRun(options, interruption)) const summary = normalizeRecipeRunSummary(output) if (options.json) await writeRecipeJsonOutput(summary, options.outputPath) else await writeRecipeSummaryHumanOutput(summary) @@ -68,7 +66,7 @@ export async function runRecipeRunCommand(args: string[]): Promise { } if (!options.json) { - const output = interruptedRecipeOutput(await execute(), interruption) + const output = await executeRecipeRun(options, interruption) printRecipeHumanOutput(output) interruption?.propagateIfInterrupted() exitAfterRecipeRunTimeout(output) @@ -77,9 +75,8 @@ export async function runRecipeRunCommand(args: string[]): Promise { return output.success ? 0 : 1 } - const { result, logs } = await captureStdout(execute) - const interruptedResult = interruptedRecipeOutput(result, interruption) - const output = logs.length > 0 ? { ...interruptedResult, logs } : interruptedResult + const { result, logs } = await captureStdout(() => executeRecipeRun(options, interruption)) + const output = logs.length > 0 ? { ...result, logs } : result await writeRecipeJsonOutput(output, options.outputPath) printJsonFailureDiagnostic(output) interruption?.propagateIfInterrupted() @@ -93,6 +90,27 @@ export async function runRecipeRunCommand(args: string[]): Promise { } } +export function createRecipeRunOptions(options: RecipeRunOptionsInput): RecipeRunOptions { + return { + previewHoldBlocking: false, + previewLeaseRequested: false, + previewLeaseChild: false, + timeoutMs: DEFAULT_RECIPE_RUN_TIMEOUT_MS, + externalServiceWritesApproved: false, + json: false, + summary: false, + dryRun: false, + ...stripUndefined(options), + } as RecipeRunOptions +} + +export async function executeRecipeRun(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise { + const output = await (options.dryRun + ? dryRunRecipe(options, { defaultWordPressVersion: DEFAULT_WORDPRESS_VERSION, resolveExecutionSpec: recipeExecutionSpec }) + : runRecipe(options, interruption)) + return interruptedRecipeOutput(output, interruption) +} + export async function runRecipeValidateCommand(args: string[]): Promise { const options = parseRecipeValidateOptions(args) const output = await validateRecipe(options) @@ -246,7 +264,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe ...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp), run: { runId: runRecord.runId, registryDirectory: runRegistry.directory }, ...(serviceEvidence.length > 0 ? { managedRuntimeServices: serviceEvidence } : {}), - ...recipeRunMetadata(recipe, recipePath, workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, backendPackage, effectivePreview), + ...recipeRunMetadata(recipe, recipePath, recipeDirectory, workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, backendPackage, effectivePreview), }, preview: previewSpec(effectivePreview.publicUrl, effectivePreview.port, effectivePreview.bind, effectivePreview.siteUrl, effectivePreview.lease), } @@ -661,8 +679,8 @@ export async function runManagedServiceCleanup( async function recipeArtifactsMountConflictFailure(options: RecipeRunOptions): Promise { const recipePath = resolve(options.recipePath) - const recipeDirectory = dirname(recipePath) - const recipe = await loadWorkspaceRecipe(recipePath) + const recipeDirectory = resolve(options.recipeDirectory ?? dirname(recipePath)) + const recipe = options.recipe ?? await loadWorkspaceRecipe(recipePath) const configuredArtifactsDirectory = options.artifactsDirectory ?? recipe.artifacts?.directory const conflict = recipeArtifactsMountConflict(recipe, recipeDirectory, configuredArtifactsDirectory) if (!conflict) { @@ -888,10 +906,10 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions { throw new Error("Missing required option: --recipe") } - return options as RecipeRunOptions + return createRecipeRunOptions({ ...options, recipePath: options.recipePath }) } -function parseRecipeRunTimeoutMs(value: unknown): number { +export function parseRecipeRunTimeoutMs(value: unknown): number { const raw = String(value).trim() const match = raw.match(/^(\d+)(ms|s|m)?$/) if (!match) { @@ -1516,7 +1534,7 @@ function effectiveRecipePreview(recipePreview: RuntimePreviewSpec | undefined, o }) } -function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspaceMounts: PreparedWorkspaceMount[], extraPlugins: PreparedExtraPlugin[], dependencyOverlays: PreparedDependencyOverlay[], stagedFiles: PreparedStagedFile[], overlays: PreparedRuntimeOverlay[], backendPackage: PreparedRuntimeBackendPackage | undefined, preview: RuntimePreviewSpec): Record { +function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, recipeDirectory: string, workspaceMounts: PreparedWorkspaceMount[], extraPlugins: PreparedExtraPlugin[], dependencyOverlays: PreparedDependencyOverlay[], stagedFiles: PreparedStagedFile[], overlays: PreparedRuntimeOverlay[], backendPackage: PreparedRuntimeBackendPackage | undefined, preview: RuntimePreviewSpec): Record { const extraPluginMetadata = extraPlugins.map((plugin) => ({ source: plugin.source, slug: plugin.slug, @@ -1529,7 +1547,7 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac })) const componentContracts = componentContractResults(recipe, extraPlugins, [], []) const componentManifest = recipeComponentManifest(extraPlugins, recipe.inputs?.component_manifest) - const siteSeedProvenance = recipeDryRunSiteSeeds(recipe, dirname(recipePath)) + const siteSeedProvenance = recipeDryRunSiteSeeds(recipe, recipeDirectory) const stagedFileProvenance = stagedFiles.map(recipeRunStagedFile) const workflow = recipeWorkflowMetadata(recipe) diff --git a/packages/cli/src/commands/wordpress-runtime.ts b/packages/cli/src/commands/wordpress-runtime.ts index f67002ff..2e29003c 100644 --- a/packages/cli/src/commands/wordpress-runtime.ts +++ b/packages/cli/src/commands/wordpress-runtime.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto" import { existsSync, realpathSync, statSync } from "node:fs" -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" -import { tmpdir } from "node:os" +import { readFile } from "node:fs/promises" import { basename, dirname, isAbsolute, join, resolve } from "node:path" import { artifactReferenceMetadata, fuzzRunnerReadinessContract, minimizeFuzzCase, parseCommandJson, parseCommandOptions, PHP_IN_PROCESS_FUZZ_SUITE_RUNNER_CAPABILITIES, runFuzzSuite, RUNTIME_BACKED_FUZZ_SUITE_RUNNER_CAPABILITIES, wordpressFuzzRuntimeContract, wordpressWorkloadRunRecipe, type ExecutionResult, type ExecutionSpec, type FuzzSuiteContract, type FuzzSuiteRuntimeWorkloadExecutionInput, type RuntimePolicy, type WordPressWorkloadRunRecipeOptions, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount } from "@automattic/wp-codebox-core" import { createWordPressEpisode, createWordPressFuzzSuiteRuntimeActionExecutor, executeWordPressFuzzSuite } from "@automattic/wp-codebox-playground/public" -import { captureStdout } from "../output.js" -import { runRecipeRunCommand } from "./recipe-run.js" +import { boundedRecipeJsonOutput } from "./recipe-run-output.js" +import { createRecipeRunOptions, executeRecipeRun, parseRecipeRunTimeoutMs } from "./recipe-run.js" +import type { RecipeRunCommandOutput } from "./recipe-run-types.js" const FUZZ_SUITE_RESULT_SCHEMA = "wp-codebox/fuzz-suite-result/v1" const WORDPRESS_WORKLOAD_RUN_RESULT_SCHEMA = "wp-codebox/wordpress-workload-run-result/v1" @@ -208,33 +208,22 @@ async function runWordPressFuzzCommand(spec: ExecutionSpec, options: PublicRunti const step = { command: spec.command, args: spec.args ?? [], ...(spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {}) } const recipe = wordpressWorkloadRunRecipe(workloadRecipeOptions({ steps: [step] }, requirements)) as WorkspaceRecipe applyFuzzSuiteRuntimeRequirements(recipe, requirements) - const tempDir = await mkdtemp(join(tmpdir(), "wp-codebox-fuzz-command-")) - try { - const recipePath = join(tempDir, "recipe.json") - await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`, "utf8") - const recipeArgs = ["--recipe", recipePath, "--json"] - if (options.dryRun) recipeArgs.push("--dry-run") - if (options.artifactsDirectory) recipeArgs.push("--artifacts", options.artifactsDirectory) - if (options.runRegistryDirectory) recipeArgs.push("--run-registry", options.runRegistryDirectory) - if (options.timeout) recipeArgs.push("--timeout", options.timeout) - const { result: exitCode, logs } = await captureStdout(() => runRecipeRunCommand(recipeArgs)) - const stdout = logs.join("") - const recipeResult = parseRecipeRunOutput(stdout) - const stderr = recipeResult?.error && typeof recipeResult.error === "object" && "message" in recipeResult.error ? String(recipeResult.error.message) : "" - return { - id: `wordpress-fuzz-command-${createHash("sha256").update(`${spec.command}\0${JSON.stringify(spec.args ?? [])}`).digest("hex").slice(0, 12)}`, - command: spec.command, - args: spec.args ?? [], - exitCode, - stdout, - stderr, - result: { schema: "wp-codebox/runtime-command-result/v1", status: exitCode === 0 ? "ok" : "error", stdout, stderr, json: recipeResult }, - startedAt, - finishedAt: new Date().toISOString(), - artifactRefs: recipeArtifactRefs(recipeResult), - } - } finally { - await rm(tempDir, { recursive: true, force: true }) + const output = await executeGeneratedRecipe(recipe, options, "fuzz-command") + const exitCode = output.success ? 0 : 1 + const recipeResult = output as unknown as Record + const stdout = `${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n` + const stderr = recipeResult.error && typeof recipeResult.error === "object" && "message" in recipeResult.error ? String(recipeResult.error.message) : "" + return { + id: `wordpress-fuzz-command-${createHash("sha256").update(`${spec.command}\0${JSON.stringify(spec.args ?? [])}`).digest("hex").slice(0, 12)}`, + command: spec.command, + args: spec.args ?? [], + exitCode, + stdout, + stderr, + result: { schema: "wp-codebox/runtime-command-result/v1", status: exitCode === 0 ? "ok" : "error", stdout, stderr, json: recipeResult }, + startedAt, + finishedAt: new Date().toISOString(), + artifactRefs: recipeArtifactRefs(recipeResult), } } @@ -245,24 +234,10 @@ export async function runWordPressWorkloadCommand(args: string[]): Promise - const tempDir = await mkdtemp(join(tmpdir(), "wp-codebox-workload-cli-")) - try { - const recipePath = join(tempDir, "recipe.json") - await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`, "utf8") - const recipeArgs = ["--recipe", recipePath, "--json"] - if (options.dryRun) recipeArgs.push("--dry-run") - if (options.artifactsDirectory) recipeArgs.push("--artifacts", options.artifactsDirectory) - if (options.runRegistryDirectory) recipeArgs.push("--run-registry", options.runRegistryDirectory) - if (options.timeout) recipeArgs.push("--timeout", options.timeout) - const { result: exitCode, logs } = await captureStdout(() => runRecipeRunCommand(recipeArgs)) - for (const log of logs) { - process.stdout.write(`${log}\n`) - } - return logs.length > 0 ? 0 : exitCode - } finally { - await rm(tempDir, { recursive: true, force: true }) - } + const recipe = wordpressWorkloadRunRecipe(workloadRecipeOptions(options.input)) as WorkspaceRecipe + const output = await executeGeneratedRecipe(recipe, options, "wordpress-workload") + writeJson(boundedRecipeJsonOutput(output)) + return 0 } async function runWordPressWorkloadFuzzCase(input: FuzzSuiteRuntimeWorkloadExecutionInput, options: PublicRuntimeCommandOptions): Promise { @@ -271,33 +246,36 @@ async function runWordPressWorkloadFuzzCase(input: FuzzSuiteRuntimeWorkloadExecu const workload = normalizeWordPressWorkloadRequest(input.workload, options.input, requirements) const recipe = wordpressWorkloadRunRecipe(workloadRecipeOptions(workload, requirements)) as WorkspaceRecipe applyFuzzSuiteRuntimeRequirements(recipe, requirements) - const tempDir = await mkdtemp(join(tmpdir(), "wp-codebox-fuzz-workload-")) - try { - const recipePath = join(tempDir, "recipe.json") - await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`, "utf8") - const recipeArgs = ["--recipe", recipePath, "--json"] - if (options.dryRun) recipeArgs.push("--dry-run") - if (options.artifactsDirectory) recipeArgs.push("--artifacts", options.artifactsDirectory) - if (options.runRegistryDirectory) recipeArgs.push("--run-registry", options.runRegistryDirectory) - if (options.timeout) recipeArgs.push("--timeout", options.timeout) - const { result: exitCode, logs } = await captureStdout(() => runRecipeRunCommand(recipeArgs)) - const stdout = logs.join("") - const recipeResult = parseRecipeRunOutput(stdout) - return { - id: `wordpress-run-workload-${input.case.id}`, - command: "wordpress.run-workload", - args: [`steps=${Array.isArray(input.workload.steps) ? input.workload.steps.length : 0}`], - exitCode, - stdout, - stderr: recipeResult?.error && typeof recipeResult.error === "object" && "message" in recipeResult.error ? String(recipeResult.error.message) : "", - result: { schema: "wp-codebox/runtime-command-result/v1", status: exitCode === 0 ? "ok" : "error", stdout, stderr: recipeResult?.error && typeof recipeResult.error === "object" && "message" in recipeResult.error ? String(recipeResult.error.message) : "", json: recipeResult }, - startedAt, - finishedAt: new Date().toISOString(), - artifactRefs: recipeArtifactRefs(recipeResult), - } - } finally { - await rm(tempDir, { recursive: true, force: true }) - } + const output = await executeGeneratedRecipe(recipe, options, "fuzz-workload") + const exitCode = output.success ? 0 : 1 + const recipeResult = output as unknown as Record + const stdout = `${JSON.stringify(boundedRecipeJsonOutput(output), null, 2)}\n` + const stderr = recipeResult.error && typeof recipeResult.error === "object" && "message" in recipeResult.error ? String(recipeResult.error.message) : "" + return { + id: `wordpress-run-workload-${input.case.id}`, + command: "wordpress.run-workload", + args: [`steps=${Array.isArray(input.workload.steps) ? input.workload.steps.length : 0}`], + exitCode, + stdout, + stderr, + result: { schema: "wp-codebox/runtime-command-result/v1", status: exitCode === 0 ? "ok" : "error", stdout, stderr, json: recipeResult }, + startedAt, + finishedAt: new Date().toISOString(), + artifactRefs: recipeArtifactRefs(recipeResult), + } +} + +async function executeGeneratedRecipe(recipe: WorkspaceRecipe, options: PublicRuntimeCommandOptions, name: string): Promise { + const digest = createHash("sha256").update(JSON.stringify(recipe)).digest("hex").slice(0, 12) + return executeRecipeRun(createRecipeRunOptions({ + recipePath: resolve(".wp-codebox", "generated-recipes", `${name}-${digest}.json`), + recipe, + recipeDirectory: process.cwd(), + artifactsDirectory: options.artifactsDirectory, + runRegistryDirectory: options.runRegistryDirectory, + timeoutMs: options.timeout ? parseRecipeRunTimeoutMs(options.timeout) : undefined, + dryRun: options.dryRun, + })) } function applyFuzzSuiteRuntimeRequirements(recipe: WorkspaceRecipe, requirements: Record | undefined): void { @@ -371,14 +349,6 @@ function runtimeRequirementEnv(base: Record | undefined, ...extr return Object.keys(merged).length > 0 ? merged : undefined } -function parseRecipeRunOutput(stdout: string): Record | undefined { - try { - return objectOption(JSON.parse(stdout.trim() || "{}")) - } catch (_error) { - return undefined - } -} - function recipeArtifactRefs(output: Record | undefined): ExecutionResult["artifactRefs"] { const artifacts = objectOption(output?.artifacts) const refs = arrayOption(artifacts?.refs ?? artifacts?.files ?? output?.artifactRefs ?? output?.artifact_refs) diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index 8103c6a8..3dc85834 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -12,6 +12,8 @@ import { runtimeServicePlan } from "./runtime-services.js" export interface RecipeDryRunOptions { recipePath: string + recipe?: WorkspaceRecipe + recipeDirectory?: string artifactsDirectory?: string policy?: RuntimePolicy } @@ -284,8 +286,8 @@ interface RecipeDryRunStep { export async function dryRunRecipe(options: RecipeDryRunOptions, context: RecipeDryRunContext): Promise { const recipePath = resolve(options.recipePath) try { - const recipeDirectory = dirname(recipePath) - const recipe = await loadWorkspaceRecipe(recipePath) + const recipeDirectory = resolve(options.recipeDirectory ?? dirname(recipePath)) + const recipe = options.recipe ?? await loadWorkspaceRecipe(recipePath) const artifactMountConflict = recipeArtifactsMountConflict(recipe, recipeDirectory, options.artifactsDirectory ?? recipe.artifacts?.directory) if (artifactMountConflict) { return { diff --git a/tests/recipe-execution-boundary.test.ts b/tests/recipe-execution-boundary.test.ts new file mode 100644 index 00000000..4f5d72bb --- /dev/null +++ b/tests/recipe-execution-boundary.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { mkdir, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import type { WorkspaceRecipe } from "../packages/runtime-core/src/index.js" +import { captureStdout } from "../packages/cli/src/output.js" +import { createRecipeRunOptions, executeRecipeRun, runRecipeRunCommand } from "../packages/cli/src/commands/recipe-run.js" +import { withTempDir } from "../scripts/test-kit.js" + +await withTempDir("wp-codebox-recipe-execution-boundary-", async (directory) => { + const mountedSource = join(directory, "mounted-source") + const artifactsDirectory = join(mountedSource, "artifacts") + const recipePath = join(directory, "recipe.json") + const recipe: WorkspaceRecipe = { + schema: "wp-codebox/workspace-recipe/v1", + inputs: { + mounts: [{ source: "mounted-source", target: "/wordpress/wp-content/plugins/example", mode: "readwrite" }], + }, + workflow: { steps: [{ command: "host/test", args: [] }] }, + } + await mkdir(mountedSource, { recursive: true }) + await writeFile(recipePath, `${JSON.stringify(recipe, null, 2)}\n`) + + const cli = await captureStdout(() => runRecipeRunCommand(["--recipe", recipePath, "--artifacts", artifactsDirectory, "--json"])) + assert.equal(cli.result, 1) + const cliOutput = JSON.parse(cli.logs.join("")) + + await rm(recipePath) + const options = createRecipeRunOptions({ recipePath, recipe, recipeDirectory: directory, artifactsDirectory }) + const typedOutput = await executeRecipeRun(options) + + assert.equal(options.timeoutMs, 25 * 60 * 1000) + assert.equal(typedOutput.success, false) + assert.deepEqual(typedOutput, cliOutput) +}) + +console.log("recipe execution boundary ok")