diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index f6327aa63..ffc77121a 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -164,17 +164,37 @@ async function verifyPublishedPullRequest(publication, targetRepo, cwd) { function projections(value, runtimeResult) { const entries = Object.entries(record(value)) const output = {} - for (const [name, source] of entries) { - if (typeof source !== "string" || !source.trim() || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(source)) { - throw new Error(`output_projections.${name} must be a dot-delimited result path.`) + for (const [name, declaration] of entries) { + const descriptor = typeof declaration === "string" ? { path: declaration, required: true } : record(declaration) + if (!/^[A-Za-z0-9_.-]+$/.test(name)) { + throw new Error(`output_projections.${name} must have a non-empty output name.`) } - const projected = resultValue(runtimeResult, source.trim()) - if (projected === undefined) throw new Error(`output_projections.${name} did not resolve from ${source}.`) + if (Object.keys(descriptor).some((key) => key !== "path" && key !== "required")) { + throw new Error(`output_projections.${name} descriptor supports only path and required.`) + } + const path = string(descriptor.path) + if (!path || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(path)) { + throw new Error(`output_projections.${name}.path must be a dot-delimited result path.`) + } + if (typeof descriptor.required !== "boolean") { + throw new Error(`output_projections.${name}.required must be a boolean.`) + } + const projected = resultValue(runtimeResult, path) + if (projected === undefined && descriptor.required) throw new Error(`output_projections.${name} did not resolve from ${path}.`) + if (projected === undefined) continue output[name] = projected } return output } +function requiredArtifacts(declarations) { + return Array.from(new Set((Array.isArray(declarations) ? declarations : []).flatMap((declaration) => { + const artifact = record(declaration) + const name = string(artifact.name) + return artifact.required === true && artifact.direction !== "input" && name ? [name] : [] + }))) +} + function workflowPath(path) { const relativePath = relative(workspace, resolve(path)) return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json" @@ -340,7 +360,7 @@ const taskInput = { runner_workspace_policy: { allowed_repos: request.access.allowed_repos }, }, artifact_declarations: request.artifacts?.declarations || [], - required_artifacts: request.artifacts?.expected || [], + required_artifacts: requiredArtifacts(request.artifacts?.declarations), output_projections: [], metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] }, }, diff --git a/.github/workflows/run-agent-task.yml b/.github/workflows/run-agent-task.yml index 093da3f51..08f18f652 100644 --- a/.github/workflows/run-agent-task.yml +++ b/.github/workflows/run-agent-task.yml @@ -84,7 +84,7 @@ name: Run Agent Task (reusable) type: number default: 600000 output_projections: - description: JSON object mapping workflow output names to result paths. + description: JSON object mapping workflow output names to paths or {path,required} descriptors. type: string default: '{}' transcript_artifact_name: @@ -96,7 +96,7 @@ name: Run Agent Task (reusable) type: string default: "" expected_artifacts: - description: JSON array of expected artifact names. + description: JSON array of expected artifact names used for collection; required outputs come from artifact_declarations.required. type: string default: '[]' artifact_declarations: diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index f5b708381..9abc93194 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -19,7 +19,7 @@ jobs: success_requires_pr: true access_token_repos: Automattic/example-target allowed_repos: '["Automattic/example-target"]' - output_projections: '{"pr_url":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url"}' + output_projections: '{"pr_url":{"path":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url","required":false}}' secrets: EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -65,7 +65,8 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c - `success_requires_pr`: require a successful, published runner-workspace pull request for `target_repo`. - `access_token_repos`: comma-separated repositories available to the supplied access token. - `allowed_repos`: JSON repository allowlist. It and `access_token_repos` must explicitly include `target_repo`. -- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. Every projection must resolve. +- `expected_artifacts`: JSON allowlist and collection metadata. Required runtime artifacts are derived only from `artifact_declarations` entries with `required: true` and must be declared output artifacts. +- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. A string value remains a required projection for compatibility. A descriptor value has the exact shape `{ "path": "result.path", "required": false }`; unresolved optional projections are omitted, while unresolved required projections fail the run. ## Access And Publication diff --git a/docs/public-api-contract.md b/docs/public-api-contract.md index e2c92ab1e..af2c3334a 100644 --- a/docs/public-api-contract.md +++ b/docs/public-api-contract.md @@ -433,9 +433,10 @@ Runtime package callers use `wp-codebox/run-runtime-package` or `wp-codebox/runtime-package-output-projection/v1`; results use `wp-codebox/runtime-package-result/v1`. `package.slug` is package identity; `package.source` is the import path. Workspace-relative sources must be -normalized against an explicit workspace root before execution. Required -artifacts are declared explicitly, runtime import failures return structured -diagnostics, and semantic outputs plus explicit typed/structured artifacts remain +normalized against an explicit workspace root before execution. Required artifacts +derive only from output declarations marked `required: true`. Runtime import +failures return structured diagnostics, and semantic outputs plus +explicit typed/structured artifacts remain separate result fields. These contracts are generic Codebox runtime/package shapes for consumers using the runtime package API. Consumers can read the same ids from `runtimeContractManifest().schemas` and diff --git a/packages/runtime-core/src/runtime-package-contracts.ts b/packages/runtime-core/src/runtime-package-contracts.ts index 64adb080a..dc0c9e3a6 100644 --- a/packages/runtime-core/src/runtime-package-contracts.ts +++ b/packages/runtime-core/src/runtime-package-contracts.ts @@ -104,7 +104,7 @@ export function normalizeRuntimePackageTask(options: RuntimePackageTaskOptions): input: isPlainObject(options.input) ? options.input : {}, artifact_declarations: normalizeRuntimePackageArtifactDeclarations(options.artifact_declarations), output_projections: normalizeRuntimePackageOutputProjections(options.output_projections), - required_artifacts: runtimePackageRequiredArtifacts(options.required_artifacts, options.artifact_declarations), + required_artifacts: runtimePackageRequiredArtifacts(options.artifact_declarations), metadata, }) as RuntimePackageTask } @@ -148,6 +148,13 @@ export function validateRuntimePackageTask(value: unknown): RuntimePackageTaskVa } if (!Array.isArray(value.required_artifacts)) { diagnostics.push(runtimePackageDiagnostic("missing_required_artifacts", "Runtime package task requires required_artifacts array.", { path: "required_artifacts" })) + } else { + const declaredRequired = runtimePackageRequiredArtifacts(value.artifact_declarations) + for (const name of stringList(value.required_artifacts)) { + if (!declaredRequired.includes(name)) { + diagnostics.push(runtimePackageDiagnostic("undeclared_required_artifact", `Runtime package required_artifacts contains ${name}, which is not declared as a required output artifact.`, { path: "required_artifacts", details: { name } })) + } + } } if (isPlainObject(value.package) && stringValue(value.package.source) && isWorkspaceRelativePackageSource(stringValue(value.package.source))) { diagnostics.push(runtimePackageDiagnostic("workspace_root_required", "Workspace-relative package.source requires explicit workspace root normalization before execution.", { path: "package.source" })) @@ -197,9 +204,7 @@ function runtimePackageWorkflow(value: unknown, fallbackId: string): RuntimePack return fallbackId ? { id: fallbackId } : undefined } -function runtimePackageRequiredArtifacts(value: unknown, declarations: unknown): string[] { - const explicit = stringList(value) - if (explicit.length > 0) return explicit +function runtimePackageRequiredArtifacts(declarations: unknown): string[] { return normalizeRuntimePackageArtifactDeclarations(declarations) .filter((artifact) => artifact.direction !== "input" && artifact.required === true) .map((artifact) => artifact.name) diff --git a/packages/runtime-core/src/runtime-package-execution.ts b/packages/runtime-core/src/runtime-package-execution.ts index 98dfaa20f..242aab770 100644 --- a/packages/runtime-core/src/runtime-package-execution.ts +++ b/packages/runtime-core/src/runtime-package-execution.ts @@ -125,8 +125,8 @@ export function normalizeRuntimePackageArtifactDeclarations(value: unknown): Run direction, required: typeof entry.required === "boolean" ? entry.required : undefined, path: stringValue(entry.path) || undefined, - contentType: stringValue(entry.contentType) || undefined, - payloadSchema: payloadSchema(entry.payloadSchema), + contentType: stringValue(entry.contentType ?? entry.content_type) || undefined, + payloadSchema: payloadSchema(entry.payloadSchema ?? entry.artifact_schema), metadata: isPlainObject(entry.metadata) ? entry.metadata : {}, })] }) diff --git a/packages/wordpress-plugin/src/class-wp-codebox-agents-api-adapter.php b/packages/wordpress-plugin/src/class-wp-codebox-agents-api-adapter.php index e75bf2e61..51310c585 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-agents-api-adapter.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-agents-api-adapter.php @@ -654,10 +654,6 @@ private static function runtime_package_workflow_for_agents_api( array $input ): /** @param array $input Runtime input. @return array */ private static function runtime_package_required_artifacts_for_agents_api( array $input ): array { - if ( is_array( $input['required_artifacts'] ?? null ) ) { - return $input; - } - $required = array(); foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) { if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) { @@ -667,9 +663,7 @@ private static function runtime_package_required_artifacts_for_agents_api( array } } } - if ( ! empty( $required ) ) { - $input['required_artifacts'] = array_values( array_unique( $required ) ); - } + $input['required_artifacts'] = array_values( array_unique( $required ) ); return $input; } diff --git a/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-service.php b/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-service.php index a96072ab4..4b2cb122f 100644 --- a/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-service.php +++ b/packages/wordpress-plugin/src/class-wp-codebox-runtime-package-service.php @@ -31,6 +31,8 @@ private function normalize_task_input( array $input ): array|WP_Error { return new WP_Error( 'wp_codebox_runtime_package_task_invalid', 'Runtime package task does not match wp-codebox/runtime-package-task/v1.', array( 'status' => 400, 'diagnostics' => $invalid ) ); } + $input['required_artifacts'] = $this->required_artifacts( $input ); + return $input; } @@ -59,6 +61,14 @@ private function task_validation_errors( array $task ): array { } if ( ! is_array( $task['required_artifacts'] ?? null ) ) { $errors[] = $this->diagnostic( 'runtime_package_task_invalid_required_artifacts', 'Runtime package task requires required_artifacts array.', 'required_artifacts' ); + } else { + $declared_required = $this->required_artifacts( $task ); + foreach ( $task['required_artifacts'] as $required ) { + $name = $this->string_value( $required ); + if ( '' !== $name && ! in_array( $name, $declared_required, true ) ) { + $errors[] = $this->diagnostic( 'runtime_package_task_undeclared_required_artifact', 'Runtime package required_artifacts contains an artifact that is not declared as a required output artifact: ' . $name . '.', 'required_artifacts' ); + } + } } return $errors; @@ -66,10 +76,6 @@ private function task_validation_errors( array $task ): array { /** @param array $input Runtime package input. @return string[] */ private function required_artifacts( array $input ): array { - if ( is_array( $input['required_artifacts'] ?? null ) ) { - return array_values( array_unique( array_filter( array_map( 'strval', $input['required_artifacts'] ) ) ) ); - } - $required = array(); foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) { if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) { diff --git a/scripts/php-runtime-package-public-contract-smoke.php b/scripts/php-runtime-package-public-contract-smoke.php index bd3ff32aa..40646d97d 100644 --- a/scripts/php-runtime-package-public-contract-smoke.php +++ b/scripts/php-runtime-package-public-contract-smoke.php @@ -114,6 +114,16 @@ function wp_get_ability( string $name ): ?WP_Codebox_Runtime_Package_Smoke_Abili assert( 'contract-runtime' === $result['metadata']['runtime_provider']['id'] ); assert( isset( $result['metadata']['received'] ) ); +$optional_artifact_task = $task; +$optional_artifact_task['artifact_declarations'] = array( + array( 'name' => 'optional-report', 'type' => 'markdown', 'required' => false ), + array( 'name' => 'required-report', 'type' => 'markdown', 'required' => true ), +); +$optional_artifact_task['required_artifacts'] = array( 'optional-report' ); +$undeclared_required = WP_Codebox_Abilities::run_runtime_package( $optional_artifact_task ); +assert( is_wp_error( $undeclared_required ) ); +assert( 'wp_codebox_runtime_package_task_invalid' === $undeclared_required->get_error_code() ); + $bundle_root = realpath( (string) ( getenv( 'WP_CODEBOX_RUNTIME_PACKAGE_FIXTURE' ) ?: __DIR__ . '/../tests/fixtures/wpsg-runtime-package' ) ); assert( false !== $bundle_root ); $staged_bundle_root = sys_get_temp_dir() . '/wp-codebox-runtime-package-' . bin2hex( random_bytes( 8 ) ); diff --git a/tests/runtime-package-execution.test.ts b/tests/runtime-package-execution.test.ts index 1e39c6ce8..a295ec725 100644 --- a/tests/runtime-package-execution.test.ts +++ b/tests/runtime-package-execution.test.ts @@ -59,11 +59,11 @@ assert.deepEqual(normalizeRuntimePackageOutputProjections([{ name: "artifactInde }]) const task = normalizeRuntimePackageTask({ - runtimePackage: "bundles/example-agent", + package: { slug: "example-agent", source: "bundles/example-agent" }, workspaceRoot: "/workspace/example-project", input: { prompt: "collect typed outputs" }, - artifactDeclarations: [{ name: "report", type: "markdown", required: true, path: "files/report.md" }], - outputProjections: [{ name: "summary", source: "result.summary", type: "text", required: true }], + artifact_declarations: [{ name: "report", type: "markdown", required: true, path: "files/report.md" }], + output_projections: [{ name: "summary", source: "result.summary", type: "text", required: true }], metadata: { caller: "contract-test" }, }) assert.equal(task.schema, RUNTIME_PACKAGE_TASK_SCHEMA) @@ -72,6 +72,20 @@ assert.deepEqual(task.workflow, { id: "example-agent" }) assert.deepEqual(task.required_artifacts, ["report"]) assert.deepEqual(validateRuntimePackageTask(task), { valid: true, task, diagnostics: [] }) +const optionalArtifactTask = normalizeRuntimePackageTask({ + package: { slug: "example-agent", source: "/workspace/example-agent" }, + input: {}, + artifact_declarations: [ + { name: "optional-report", type: "markdown", required: false }, + { name: "required-report", type: "markdown", required: true }, + ], + required_artifacts: ["optional-report"], +}) +assert.deepEqual(optionalArtifactTask.required_artifacts, ["required-report"], "required artifacts derive only from required declarations") +const undeclaredRequired = validateRuntimePackageTask({ ...optionalArtifactTask, required_artifacts: ["optional-report"] }) +assert.equal(undeclaredRequired.valid, false) +assert.equal(undeclaredRequired.diagnostics.at(-1)?.code, "undeclared_required_artifact") + const missingPublicFields = validateRuntimePackageTask({ schema: RUNTIME_PACKAGE_TASK_SCHEMA, package: { slug: "example-agent" } }) assert.equal(missingPublicFields.valid, false) assert.deepEqual(missingPublicFields.diagnostics.map((diagnostic) => diagnostic.code), [ diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts index eef30f4b6..e3b119a2c 100644 --- a/tests/runtime-sources-materialization.test.ts +++ b/tests/runtime-sources-materialization.test.ts @@ -220,7 +220,7 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { await writeFile(join(tools, "git"), `#!${process.execPath}\nimport { spawnSync } from "node:child_process"\nconst args = process.argv.slice(2).map((value) => value.startsWith("https://github.com/") ? ${JSON.stringify(repository)} : value)\nconst result = spawnSync(${JSON.stringify(gitPath)}, args, { stdio: "inherit" })\nprocess.exit(result.status ?? 1)\n`) await chmod(join(tools, "git"), 0o755) const capturedInput = join(directory, "captured-native-input.json") - await writeFile(join(directory, "fake-cli.mjs"), `import { readFile, writeFile } from "node:fs/promises"\nconst input = process.argv[process.argv.indexOf("--input-file") + 1]\nconst result = process.argv[process.argv.indexOf("--result-file") + 1]\nconst taskInput = JSON.parse(await readFile(input))\nconst runtimeRoot = taskInput.source_package_root.replace(/\\/prepared-runtime-sources$/, "")\nconst nativeResult = JSON.parse(${JSON.stringify(JSON.stringify(hostedPathRegression.success))}.replaceAll(${JSON.stringify(hostedPathRegression.runtime_root)}, runtimeRoot))\nawait writeFile(${JSON.stringify(capturedInput)}, JSON.stringify(taskInput))\nawait writeFile(result, JSON.stringify(nativeResult))\n`) + await writeFile(join(directory, "fake-cli.mjs"), `import { readFile, writeFile } from "node:fs/promises"\nconst input = process.argv[process.argv.indexOf("--input-file") + 1]\nconst result = process.argv[process.argv.indexOf("--result-file") + 1]\nconst taskInput = JSON.parse(await readFile(input))\nconst runtimeRoot = taskInput.source_package_root.replace(/\\/prepared-runtime-sources$/, "")\nconst nativeResult = JSON.parse(${JSON.stringify(JSON.stringify(hostedPathRegression.success))}.replaceAll(${JSON.stringify(hostedPathRegression.runtime_root)}, runtimeRoot))\nnativeResult.status = "no_op"\nnativeResult.agent_task_run_result.status = "no_op"\nawait writeFile(${JSON.stringify(capturedInput)}, JSON.stringify(taskInput))\nawait writeFile(result, JSON.stringify(nativeResult))\n`) const request = { workload: { id: "runtime-sources-workflow", label: "Runtime sources workflow" }, access: { caller_repo: "example/target", allowed_repos: ["example/target"], access_token_repos: ["example/target"] }, @@ -233,9 +233,9 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { runtime_sources: [{ version: 1, role: "provider_plugin", repository: "example/source", revision, path: "plugin", metadata: { slug: "fixture", pluginFile: "plugin.php", activate: true, providers: ["openai"] } }], verification_commands: [], drift_checks: [], - artifacts: { declarations: [], expected: [] }, + artifacts: { declarations: [{ name: "optional-publication", type: "publication", required: false }], expected: ["optional-publication"] }, callback_data: {}, - outputs: { projections: {} }, + outputs: { projections: { optional_publication: { path: "outputs.artifact_result.result.outputs.runner_workspace_publication", required: false } } }, success: { requires_pr: false }, } await writeFile(join(codebox, "agent-task-request.json"), JSON.stringify(request)) @@ -254,6 +254,17 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { assert.deepEqual(nativeInput.task_input.runtime_task.input.input.model, "gpt-5.5") assert.equal("provider" in nativeInput.task_input, false, "provider belongs only to the selected runtime package turn") assert.equal("model" in nativeInput.task_input, false, "model belongs only to the selected runtime package turn") + assert.deepEqual(nativeInput.task_input.expected_artifacts, ["optional-publication"], "expected artifacts remain collection metadata") + assert.deepEqual(nativeInput.task_input.runtime_task.input.required_artifacts, [], "optional declarations never become required runtime artifacts") + assert.match(nativeInput.source_package_root, /prepared-runtime-sources$/, "runtime preparation stays in the private source root") + assert.equal(nativeInput.source_package_root, join(exactRuntimeRoot, "prepared-runtime-sources"), "the native task mounts the exact private preparation root") + assert.doesNotMatch(nativeInput.source_package_root, /agent-task-artifacts/, "runtime sources are mounted outside workspace artifacts") + const noOpResult = JSON.parse(workflowResult) + assert.equal(noOpResult.success, true, "a verifier-clean no-op with optional outputs succeeds") + assert.equal(noOpResult.status, "succeeded") + assert.equal(noOpResult.runtime_result.status, "no_op") + assert.deepEqual(noOpResult.outputs.projections, {}, "missing optional projections are omitted") + assert.equal("projection_error" in noOpResult, false) await assert.rejects(access(join(codebox, "native-agent-task-input.json")), /ENOENT/, "runtime source native input must remain private") const uploaderPath = new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url) const output = await readFile(githubOutput, "utf8")