Skip to content

Commit 486ea59

Browse files
authored
fix: preserve agent task workflow JSON outputs (#1834)
1 parent 95417fb commit 486ea59

4 files changed

Lines changed: 217 additions & 14 deletions

File tree

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const codeboxRoot = resolve(process.env.WP_CODEBOX_WORKFLOW_ROOT || ".")
1616
const codeboxCliPath = process.env.WP_CODEBOX_CLI_PATH || join(codeboxRoot, "packages/cli/dist/index.js")
1717
const outputPath = process.env.GITHUB_OUTPUT
1818
const MAX_CAPTURE_BYTES = 32768
19-
const MAX_OUTPUT_CHARS = 8192
19+
const MAX_WORKFLOW_OUTPUT_BYTES = 8192
2020
const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
2121
let privateRuntimeSourceRoot = ""
2222
let privateRuntimeSourceRootForSanitization = ""
@@ -97,7 +97,53 @@ function capturedStream(limit = MAX_CAPTURE_BYTES) {
9797
function output(name, value) {
9898
if (!outputPath) return Promise.resolve()
9999
const rendered = typeof value === "string" ? value : JSON.stringify(value)
100-
return appendFile(outputPath, `${name}<<__WP_CODEBOX_OUTPUT__\n${bounded(rendered, MAX_OUTPUT_CHARS)}\n__WP_CODEBOX_OUTPUT__\n`)
100+
const safe = sanitizeRuntimeSourceText(redact(rendered), privateRuntimeSourceRootForSanitization)
101+
const bytes = Buffer.byteLength(safe)
102+
if (bytes > MAX_WORKFLOW_OUTPUT_BYTES) {
103+
throw new Error(`${name} exceeds the ${MAX_WORKFLOW_OUTPUT_BYTES}-byte workflow output limit.`)
104+
}
105+
return appendFile(outputPath, `${name}<<__WP_CODEBOX_OUTPUT__\n${safe}\n__WP_CODEBOX_OUTPUT__\n`)
106+
}
107+
108+
function serializedJson(value) {
109+
return JSON.stringify(value)
110+
}
111+
112+
function workflowOutputReference(name, rendered, artifactsPath) {
113+
const bytes = Buffer.byteLength(rendered)
114+
if (bytes <= MAX_WORKFLOW_OUTPUT_BYTES) return Promise.resolve({ rendered })
115+
116+
const artifactPath = `workflow-outputs/${name}.json`
117+
const reference = {
118+
schema: "wp-codebox/workflow-output-reference/v1",
119+
kind: "codebox-workflow-output",
120+
output: name,
121+
artifact_path: artifactPath,
122+
bytes,
123+
sha256: createHash("sha256").update(rendered).digest("hex"),
124+
}
125+
return mkdir(join(artifactsPath, "workflow-outputs"), { recursive: true })
126+
.then(() => writeFile(join(artifactsPath, artifactPath), `${rendered}\n`))
127+
.then(() => ({ rendered: serializedJson(reference), reference }))
128+
}
129+
130+
function projectionOutputLimitError(name, bytes) {
131+
const error = new Error(`output_projections.${name} serializes to ${bytes} bytes, exceeding the ${MAX_WORKFLOW_OUTPUT_BYTES}-byte workflow output limit. Store the canonical value as a declared artifact and project its artifact-relative reference.`)
132+
error.code = "wp-codebox.agent-task.output-projection-too-large"
133+
error.output_name = name
134+
error.bytes = bytes
135+
error.max_bytes = MAX_WORKFLOW_OUTPUT_BYTES
136+
return error
137+
}
138+
139+
function validateProjectionOutputSize(value) {
140+
for (const [name, projected] of Object.entries(value)) {
141+
const projectedBytes = Buffer.byteLength(serializedJson(projected))
142+
if (projectedBytes > MAX_WORKFLOW_OUTPUT_BYTES) throw projectionOutputLimitError(name, projectedBytes)
143+
}
144+
const rendered = serializedJson(value)
145+
const bytes = Buffer.byteLength(rendered)
146+
if (bytes > MAX_WORKFLOW_OUTPUT_BYTES) throw projectionOutputLimitError("projected_outputs_json", bytes)
101147
}
102148

103149
function safeEnvironment(extra = {}) {
@@ -355,7 +401,7 @@ function failureClassification(error) {
355401
async function writeNormalizedFailure(error, request = {}) {
356402
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
357403
const failure = failureClassification(error)
358-
const message = bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS)
404+
const message = bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES)
359405
const accessError = failure.classification === "policy" && /allowed_repos|ACCESS_TOKEN|GitHub token|Caller repository|authorized/i.test(message)
360406
const result = {
361407
schema: "wp-codebox/agent-task-workflow-result/v1",
@@ -592,7 +638,7 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor
592638
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
593639
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity })
594640
} catch (error) {
595-
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS), ...(error?.evidence ? { evidence: error.evidence } : {}) }
641+
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) }
596642
}
597643
}
598644
runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots)
@@ -637,18 +683,26 @@ if (execution.code === 0 && runtimeRecord.success === true && verificationPassed
637683
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
638684
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
639685
} catch (error) {
640-
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS) }
686+
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES) }
641687
}
642688
}
643689
if (request.success?.requires_pr === true && workspaceApply.status === "no-op" && !publication) {
644690
downstreamFailure ??= { stage: "no-op", message: "success_requires_pr cannot succeed for a no-op runner workspace task." }
645691
}
646692
let evaluatedProjections = {}
647-
let projectionError = ""
693+
let projectionError
648694
try {
649695
evaluatedProjections = projections(request.outputs?.projections, runtimeRecord)
696+
validateProjectionOutputSize(evaluatedProjections)
650697
} catch (error) {
651-
projectionError = error instanceof Error ? error.message : String(error)
698+
projectionError = {
699+
code: typeof error?.code === "string" ? error.code : "wp-codebox.agent-task.output-projection",
700+
classification: "output-projection",
701+
message: error instanceof Error ? error.message : String(error),
702+
...(typeof error?.output_name === "string" ? { output_name: error.output_name } : {}),
703+
...(Number.isSafeInteger(error?.bytes) ? { bytes: error.bytes } : {}),
704+
...(Number.isSafeInteger(error?.max_bytes) ? { max_bytes: error.max_bytes } : {}),
705+
}
652706
}
653707
const publicationRequired = request.success?.requires_pr === true
654708
const publicationVerification = publicationRequired && execution.code === 0 && runtimeRecord.success === true
@@ -662,6 +716,12 @@ const success = request.run_agent && !request.dry_run
662716
? execution.code === 0 && runtimeRecord.success === true && verificationPassed && publicationPassed && !projectionError && !downstreamFailure
663717
: true
664718
const status = request.run_agent && !request.dry_run ? (success ? "succeeded" : "failed") : "skipped"
719+
const workflowOutputs = {
720+
transcript_json: await workflowOutputReference("transcript_json", serializedJson(agentResult.refs?.transcripts || []), artifactsPath),
721+
engine_data_json: await workflowOutputReference("engine_data_json", serializedJson(redact(record(runtimeRecord.outputs))), artifactsPath),
722+
projected_outputs_json: { rendered: serializedJson(projectionError ? { error: projectionError } : evaluatedProjections) },
723+
declared_artifacts_json: await workflowOutputReference("declared_artifacts_json", serializedJson(request.artifacts?.declarations || []), artifactsPath),
724+
}
665725
const result = {
666726
schema: "wp-codebox/agent-task-workflow-result/v1",
667727
run_id: runId,
@@ -680,6 +740,10 @@ const result = {
680740
engine_data: record(runtimeRecord.outputs),
681741
projections: evaluatedProjections,
682742
},
743+
...(Object.values(workflowOutputs).some((output) => output.reference) ? {
744+
workflow_output_artifacts: Object.fromEntries(Object.entries(workflowOutputs)
745+
.flatMap(([name, output]) => output.reference ? [[name, output.reference]] : [])),
746+
} : {}),
683747
access: { authorized: true, credential_mode: process.env.GITHUB_TOKEN ? "runner-access-token" : (process.env.OPENAI_API_KEY ? "runner-provider-credentials" : "runner-default-credentials"), policy: { allowed_repos: request.access.allowed_repos } },
684748
...(publicationRequired ? { publication_verification: publicationVerification } : {}),
685749
...(publicationRequired && !publicationPassed ? { publication_error: "success_requires_pr requires a valid published runner-workspace pull request for target_repo." } : {}),
@@ -691,12 +755,12 @@ const sanitizedResult = sanitizeRuntimeSourceValue(redact(result), privateRuntim
691755
assertNoRuntimeSourcePaths(sanitizedResult, privateRuntimeSourceRootForSanitization)
692756
await writeFile(resultPath, `${JSON.stringify(sanitizedResult, null, 2)}\n`)
693757
await output("job_status", status)
694-
await output("transcript_json", JSON.stringify(agentResult.refs?.transcripts || []))
758+
await output("transcript_json", workflowOutputs.transcript_json.rendered)
695759
await output("transcript_summary", `${request.workload?.label || "Run Agent Task"}: ${status}`)
696-
await output("engine_data_json", result.outputs.engine_data)
697-
await output("projected_outputs_json", result.outputs.projections)
760+
await output("engine_data_json", workflowOutputs.engine_data_json.rendered)
761+
await output("projected_outputs_json", workflowOutputs.projected_outputs_json.rendered)
698762
await output("credential_mode", result.access.credential_mode)
699-
await output("declared_artifacts_json", result.artifacts.declarations)
763+
await output("declared_artifacts_json", workflowOutputs.declared_artifacts_json.rendered)
700764
await output("result_path", ".codebox/agent-task-workflow-result.json")
701765

702766
if (!success) process.exitCode = 1
@@ -706,7 +770,7 @@ try {
706770
await executeNativeAgentTask()
707771
} catch (error) {
708772
await writeNormalizedFailure(error)
709-
console.error(bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS))
773+
console.error(bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES))
710774
process.exitCode = 1
711775
} finally {
712776
await cleanupMaterializedSources()

.github/scripts/run-agent-task/prepare-agent-task-upload.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,24 @@ function declaredArtifactPaths(result, allowed) {
419419
return [...paths].sort()
420420
}
421421

422+
function workflowOutputArtifacts(result) {
423+
return Object.entries(record(result.workflow_output_artifacts)).flatMap(([name, value]) => {
424+
const reference = record(value)
425+
const path = safeRelativeArtifactPath(reference.artifact_path)
426+
if (reference.schema !== "wp-codebox/workflow-output-reference/v1"
427+
|| reference.kind !== "codebox-workflow-output"
428+
|| reference.output !== name
429+
|| !/^[A-Za-z0-9_.-]+$/.test(name)
430+
|| path !== `workflow-outputs/${name}.json`
431+
|| !Number.isSafeInteger(reference.bytes)
432+
|| reference.bytes <= 0
433+
|| !/^[a-f0-9]{64}$/.test(reference.sha256)) {
434+
throw new Error("Workflow output artifact reference is malformed.")
435+
}
436+
return [[path, reference]]
437+
})
438+
}
439+
422440
async function exclusions(root, declaredPaths, transcript) {
423441
const counts = new Map()
424442
const count = (category) => counts.set(category, (counts.get(category) || 0) + 1)
@@ -479,6 +497,7 @@ const request = parseJsonOrEmpty(await readFile(requestPath, "utf8").catch(() =>
479497
const resultSource = join(workspace, ".codebox", "agent-task-workflow-result.json")
480498
const result = parseJsonOrEmpty(await readFile(resultSource, "utf8").catch(() => "{}"))
481499
const declaredPaths = new Set(declaredArtifactPaths(result, declarations(request)))
500+
const workflowOutputArtifactsToStage = workflowOutputArtifacts(result)
482501

483502
await rm(uploadPath, { recursive: true, force: true })
484503
await mkdir(uploadPath, { recursive: true })
@@ -495,6 +514,16 @@ for (const path of declaredPaths) {
495514
}
496515
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
497516
}
517+
for (const [path, reference] of workflowOutputArtifactsToStage) {
518+
const source = resolve(artifactsPath, path)
519+
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) continue
520+
const bytes = await readFile(source)
521+
const canonical = bytes.subarray(bytes.length - 1)[0] === 10 ? bytes.subarray(0, -1) : bytes
522+
if (canonical.length !== reference.bytes || digest(canonical) !== reference.sha256) {
523+
throw new Error("Workflow output artifact does not match its reference.")
524+
}
525+
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
526+
}
498527
const transcript = await stageCanonicalTranscript(result)
499528
if (transcript?.provenance?.source_sha256) {
500529
const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json")

docs/agent-task-reusable-workflow.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
6666
- `access_token_repos`: comma-separated repositories available to the supplied access token.
6767
- `allowed_repos`: JSON repository allowlist. It and `access_token_repos` must explicitly include `target_repo`.
6868
- `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.
69-
- `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.
69+
- `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. Each evaluated projection and the combined projection object must serialize to 8,192 bytes or less. Oversized projections fail before success with `wp-codebox.agent-task.output-projection-too-large`; declare an artifact and project its artifact-relative reference instead.
7070

7171
## Access And Publication
7272

@@ -82,7 +82,7 @@ fail closed for every PR, issue, and comment operation outside that set. The
8282
checkout does not persist credentials. Verification, dependency, and drift
8383
commands run with a clean environment that excludes provider and GitHub secrets.
8484
Known secret values are redacted before result or artifact persistence; captured
85-
stdout/stderr is capped at 32 KiB and workflow outputs at 8 KiB.
85+
stdout/stderr is capped at 32 KiB and workflow outputs at 8,192 bytes.
8686

8787
`EXTERNAL_PACKAGE_SOURCE_POLICY` is treated as a secret even when it contains
8888
only repository and path metadata. It is redacted from runner output, excluded
@@ -137,6 +137,15 @@ runner token, so a fabricated publication result cannot satisfy the gate.
137137
- `credential_mode`: redacted credential source classification.
138138
- `declared_artifacts_json`: accepted typed artifact declarations.
139139

140+
Every structured workflow output is serialized as complete JSON. When a
141+
system-owned structured value exceeds 8,192 bytes, its workflow output becomes a
142+
`wp-codebox/workflow-output-reference/v1` object with an artifact-relative path,
143+
byte count, and SHA-256; the canonical JSON is collected from
144+
`.codebox/agent-task-artifacts/workflow-outputs/`. The workflow result records
145+
these references in `workflow_output_artifacts`. User-declared projections are
146+
validated instead of truncated, so a success result never omits or corrupts a
147+
projection.
148+
140149
The result artifact includes the executable task input, normalized runtime
141150
result, evaluated projections, verification records, and runner-owned
142151
publication result. A single canonical `codebox-transcript` ref from the

0 commit comments

Comments
 (0)