Skip to content

Commit 65efc1c

Browse files
authored
Add portable workspace delta contract (#1831)
1 parent a2b02cd commit 65efc1c

5 files changed

Lines changed: 198 additions & 3 deletions

File tree

packages/runtime-core/src/headless-agent-task-contracts.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AGENT_TASK_RUN_RESULT_SCHEMA, type AgentTaskRunResultSummary } from "./
22
import { RUNTIME_PROFILE_SCHEMA, normalizeRuntimeAccess, normalizeRuntimeProfile, type RuntimeAccess, type RuntimeProfile } from "./runtime-boundary-contracts.js"
33
import { TASK_INPUT_JSON_SCHEMA, normalizeTaskInput, type TaskInput, type TaskInputRequest } from "./task-input.js"
44
import { isPlainObject, objectValue, stringList, stringValue, stripUndefined } from "./object-utils.js"
5+
import { WORKSPACE_DELTA_JSON_SCHEMA, workspaceDeltaFromAgentTaskRunResult, type WorkspaceDelta } from "./workspace-delta.js"
56

67
export const HEADLESS_AGENT_TASK_REQUEST_SCHEMA = "wp-codebox/headless-agent-task-request/v1" as const
78
export const HEADLESS_AGENT_TASK_RESULT_SCHEMA = "wp-codebox/headless-agent-task-result/v1" as const
@@ -34,6 +35,7 @@ export interface HeadlessAgentTaskResult {
3435
refs: AgentTaskRunResultSummary["refs"]
3536
artifacts: AgentTaskRunResultSummary["artifacts"]
3637
evidence_refs: AgentTaskRunResultSummary["refs"]["evidence_bundles"]
38+
workspace_delta: WorkspaceDelta
3739
diagnostics: Array<Record<string, unknown>>
3840
metadata: Record<string, unknown>
3941
agent_task_run_result: AgentTaskRunResultSummary
@@ -69,7 +71,7 @@ export const HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA = {
6971
export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = {
7072
$id: HEADLESS_AGENT_TASK_RESULT_SCHEMA,
7173
type: "object",
72-
required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "diagnostics", "metadata", "agent_task_run_result"],
74+
required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "workspace_delta", "diagnostics", "metadata", "agent_task_run_result"],
7375
additionalProperties: false,
7476
properties: {
7577
schema: { type: "string", const: HEADLESS_AGENT_TASK_RESULT_SCHEMA },
@@ -80,6 +82,7 @@ export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = {
8082
refs: { type: "object" },
8183
artifacts: { type: "array", items: { type: "object" } },
8284
evidence_refs: { type: "array", items: { type: "object" } },
85+
workspace_delta: WORKSPACE_DELTA_JSON_SCHEMA,
8386
diagnostics: { type: "array", items: { type: "object" } },
8487
metadata: { type: "object" },
8588
agent_task_run_result: { type: "object", properties: { schema: { type: "string", const: AGENT_TASK_RUN_RESULT_SCHEMA } } },
@@ -124,6 +127,7 @@ export function headlessAgentTaskRequestToRunInput(request: HeadlessAgentTaskReq
124127
}
125128

126129
export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSummary, metadata: Record<string, unknown> = {}): HeadlessAgentTaskResult {
130+
const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(result)
127131
return {
128132
schema: HEADLESS_AGENT_TASK_RESULT_SCHEMA,
129133
success: result.success,
@@ -133,7 +137,8 @@ export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSumma
133137
refs: result.refs,
134138
artifacts: result.artifacts,
135139
evidence_refs: result.refs.evidence_bundles,
136-
diagnostics: result.diagnostics,
140+
workspace_delta: workspaceDelta,
141+
diagnostics: result.diagnostics.concat(workspaceDelta.diagnostics),
137142
metadata: stripUndefined({ ...metadata, ...result.metadata }),
138143
agent_task_run_result: result,
139144
}

packages/runtime-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export * from "./structured-artifacts.js"
3737
export * from "./tool-call-artifacts.js"
3838
export * from "./agent-task-run-result.js"
3939
export * from "./headless-agent-task-contracts.js"
40+
export * from "./workspace-delta.js"
4041
export * from "./status-taxonomy.js"
4142
export * from "./agent-terminal-result.js"
4243
export * from "./agent-runtime-workload.js"

packages/runtime-core/src/public.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export * from "./task-input.js"
8181
export * from "./tool-call-artifacts.js"
8282
export * from "./transfer-proof.js"
8383
export * from "./workspace-policy.js"
84+
export * from "./workspace-delta.js"
8485
export * from "./workspace-preload-artifacts.js"
8586
export * from "./wordpress-crud-contracts.js"
8687
export * from "./wordpress-block-exercise-contracts.js"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { safeArtifactRelativePath } from "./artifact-paths.js"
2+
import type { AgentTaskRunArtifactRef, AgentTaskRunResultSummary } from "./agent-task-run-result.js"
3+
import { stripUndefined } from "./object-utils.js"
4+
5+
export const WORKSPACE_DELTA_SCHEMA = "wp-codebox/workspace-delta/v1" as const
6+
7+
export interface WorkspaceDeltaArtifactRef {
8+
kind: "codebox-changed-files" | "codebox-patch"
9+
path: string
10+
sha256?: string
11+
size_bytes?: number
12+
}
13+
14+
export interface WorkspaceDeltaDiagnostic extends Record<string, unknown> {
15+
code: "workspace_delta.artifact_not_portable" | "workspace_delta.incomplete"
16+
message: string
17+
}
18+
19+
export interface WorkspaceDelta {
20+
schema: typeof WORKSPACE_DELTA_SCHEMA
21+
status: "changed" | "no_op" | "unavailable"
22+
changed_files_count?: number
23+
patch_bytes?: number
24+
changed_files?: WorkspaceDeltaArtifactRef
25+
patch?: WorkspaceDeltaArtifactRef
26+
diagnostics: WorkspaceDeltaDiagnostic[]
27+
}
28+
29+
export const WORKSPACE_DELTA_JSON_SCHEMA = {
30+
$id: WORKSPACE_DELTA_SCHEMA,
31+
type: "object",
32+
required: ["schema", "status", "diagnostics"],
33+
additionalProperties: false,
34+
properties: {
35+
schema: { const: WORKSPACE_DELTA_SCHEMA },
36+
status: { enum: ["changed", "no_op", "unavailable"] },
37+
changed_files_count: { type: "integer", minimum: 0 },
38+
patch_bytes: { type: "integer", minimum: 0 },
39+
changed_files: workspaceDeltaArtifactJsonSchema("codebox-changed-files"),
40+
patch: workspaceDeltaArtifactJsonSchema("codebox-patch"),
41+
diagnostics: {
42+
type: "array",
43+
items: {
44+
type: "object",
45+
required: ["code", "message"],
46+
additionalProperties: false,
47+
properties: {
48+
code: { enum: ["workspace_delta.artifact_not_portable", "workspace_delta.incomplete"] },
49+
message: { type: "string" },
50+
},
51+
},
52+
},
53+
},
54+
} as const
55+
56+
/**
57+
* Produces the Codebox-owned change handoff. Consumers apply it; Codebox never
58+
* exposes the sandbox's host paths as part of that handoff.
59+
*/
60+
export function workspaceDeltaFromAgentTaskRunResult(result: AgentTaskRunResultSummary): WorkspaceDelta {
61+
const changedFiles = portableWorkspaceArtifact(result.refs.changed_files[0], "codebox-changed-files")
62+
const patch = portableWorkspaceArtifact(result.refs.patches[0], "codebox-patch")
63+
const diagnostics: WorkspaceDeltaDiagnostic[] = []
64+
65+
for (const artifact of [changedFiles, patch]) {
66+
if (artifact.diagnostic) diagnostics.push(artifact.diagnostic)
67+
}
68+
69+
if (result.no_op.detected) {
70+
return { schema: WORKSPACE_DELTA_SCHEMA, status: "no_op", changed_files_count: 0, patch_bytes: 0, diagnostics }
71+
}
72+
73+
if (!changedFiles.ref || !patch.ref) {
74+
diagnostics.push({
75+
code: "workspace_delta.incomplete",
76+
message: "Workspace delta requires portable changed-files and patch artifacts.",
77+
})
78+
return { schema: WORKSPACE_DELTA_SCHEMA, status: "unavailable", diagnostics }
79+
}
80+
81+
return stripUndefined({
82+
schema: WORKSPACE_DELTA_SCHEMA,
83+
status: "changed" as const,
84+
changed_files_count: nonNegativeInteger(result.no_op.changed_files_count),
85+
patch_bytes: nonNegativeInteger(result.no_op.patch_bytes),
86+
changed_files: changedFiles.ref,
87+
patch: patch.ref,
88+
diagnostics,
89+
}) as WorkspaceDelta
90+
}
91+
92+
function portableWorkspaceArtifact(artifact: AgentTaskRunArtifactRef | undefined, kind: WorkspaceDeltaArtifactRef["kind"]): { ref?: WorkspaceDeltaArtifactRef; diagnostic?: WorkspaceDeltaDiagnostic } {
93+
if (!artifact?.path) return {}
94+
95+
try {
96+
const path = artifact.path.trim().replace(/\\/g, "/")
97+
if (path.startsWith("/") || /^[A-Za-z]:($|\/)/.test(path)) {
98+
throw new Error("Workspace delta artifact paths must be relative.")
99+
}
100+
return {
101+
ref: stripUndefined({
102+
kind,
103+
path: safeArtifactRelativePath(path),
104+
sha256: artifact.sha256,
105+
size_bytes: nonNegativeInteger(artifact.size_bytes),
106+
}) as WorkspaceDeltaArtifactRef,
107+
}
108+
} catch {
109+
return {
110+
diagnostic: {
111+
code: "workspace_delta.artifact_not_portable",
112+
message: `Codebox emitted a non-portable ${kind} artifact reference.`,
113+
},
114+
}
115+
}
116+
}
117+
118+
function workspaceDeltaArtifactJsonSchema(kind: WorkspaceDeltaArtifactRef["kind"]) {
119+
return {
120+
type: "object",
121+
required: ["kind", "path"],
122+
additionalProperties: false,
123+
properties: {
124+
kind: { const: kind },
125+
path: { type: "string", minLength: 1 },
126+
sha256: { type: "string", minLength: 1 },
127+
size_bytes: { type: "integer", minimum: 0 },
128+
},
129+
}
130+
}
131+
132+
function nonNegativeInteger(value: unknown): number | undefined {
133+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined
134+
}

tests/agent-task-contracts.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync }
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { chdir, cwd } from "node:process"
6-
import { AGENT_TASK_RUN_REQUEST_SCHEMA, AGENT_TASK_RUN_RESULT_JSON_SCHEMA, AGENT_TASK_RUN_RESULT_SCHEMA, ARTIFACT_RESULT_ENVELOPE_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA, HEADLESS_AGENT_TASK_RESULT_SCHEMA, PREVIEW_LEASE_SCHEMA, TYPED_ARTIFACT_SCHEMA, buildAgentTaskRecipe, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeRecipeRunSummary, normalizeTaskInput } from "../packages/runtime-core/src/index.js"
6+
import { AGENT_TASK_RUN_REQUEST_SCHEMA, AGENT_TASK_RUN_RESULT_JSON_SCHEMA, AGENT_TASK_RUN_RESULT_SCHEMA, ARTIFACT_RESULT_ENVELOPE_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA, HEADLESS_AGENT_TASK_RESULT_SCHEMA, PREVIEW_LEASE_SCHEMA, TYPED_ARTIFACT_SCHEMA, WORKSPACE_DELTA_JSON_SCHEMA, WORKSPACE_DELTA_SCHEMA, buildAgentTaskRecipe, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeRecipeRunSummary, normalizeTaskInput, workspaceDeltaFromAgentTaskRunResult } from "../packages/runtime-core/src/index.js"
77
import { effectivePolicyCommands } from "../packages/runtime-core/src/contracts.js"
88
import { commandCatalogOutput } from "../packages/cli/src/commands/discovery.js"
99
import { agentTaskResultFromRun, agentTaskRunExitCode, agentTaskRunJsonOutput, normalizeAgentTaskRunCliInput, writeAgentTaskRunResultFile } from "../packages/cli/src/commands/agent-task-run.js"
@@ -222,6 +222,60 @@ assert.equal(headlessResult.preview?.public_url, "https://preview.example.test/"
222222
assert.equal(headlessResult.evidence_refs[0].kind, "codebox-evidence-bundle")
223223
assert.equal(headlessResult.agent_task_run_result.schema, AGENT_TASK_RUN_RESULT_SCHEMA)
224224

225+
const workspaceDeltaResult = normalizeAgentTaskRunResult({
226+
success: true,
227+
artifacts: [
228+
{ kind: "codebox-changed-files", path: "files/changed-files.json", bytes: 128, sha256: "changed" },
229+
{ kind: "codebox-patch", path: "files/patch.diff", bytes: 256, sha256: "patch" },
230+
],
231+
agent_result: {
232+
changedFiles: { artifact: "files/changed-files.json", bytes: 128, count: 1, sha256: "changed" },
233+
patch: { artifact: "files/patch.diff", bytes: 256, sha256: "patch" },
234+
},
235+
})
236+
const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(workspaceDeltaResult)
237+
assert.equal(workspaceDelta.schema, WORKSPACE_DELTA_SCHEMA)
238+
assert.equal(workspaceDelta.status, "changed")
239+
assert.equal(workspaceDelta.changed_files?.path, "files/changed-files.json")
240+
assert.equal(workspaceDelta.patch?.path, "files/patch.diff")
241+
assert.equal(normalizeHeadlessAgentTaskResult(workspaceDeltaResult).workspace_delta.status, "changed")
242+
assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.additionalProperties, false)
243+
assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.properties.changed_files.required.includes("path"), true)
244+
245+
assert.equal(workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ success: true, no_op: true })).status, "no_op")
246+
247+
const hostPathDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
248+
success: true,
249+
artifacts: [
250+
{ kind: "codebox-changed-files", path: "/private/codebox/files/changed-files.json" },
251+
{ kind: "codebox-patch", path: "/private/codebox/files/patch.diff" },
252+
],
253+
}))
254+
assert.equal(hostPathDelta.status, "unavailable")
255+
assert.equal(hostPathDelta.diagnostics.filter((diagnostic) => diagnostic.code === "workspace_delta.artifact_not_portable").length, 2)
256+
257+
for (const path of ["\\private\\codebox\\files\\changed-files.json", "\\\\host\\share\\files\\changed-files.json", "C:\\codebox\\files\\changed-files.json", "files/../changed-files.json"]) {
258+
const malformedDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
259+
success: true,
260+
artifacts: [
261+
{ kind: "codebox-changed-files", path },
262+
{ kind: "codebox-patch", path: "files/patch.diff" },
263+
],
264+
}))
265+
assert.equal(malformedDelta.status, "unavailable")
266+
assert.equal(malformedDelta.changed_files, undefined)
267+
assert.equal(JSON.stringify(malformedDelta).includes("private/codebox"), false, "workspace delta never leaks a host artifact path")
268+
}
269+
270+
const inferredFilenameDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
271+
success: true,
272+
artifacts: [
273+
{ kind: "artifact", path: "files/changed-files.json" },
274+
{ kind: "artifact", path: "files/patch.diff" },
275+
],
276+
}))
277+
assert.equal(inferredFilenameDelta.status, "unavailable", "workspace delta only accepts explicitly typed artifacts")
278+
225279
const headlessJsonOutput = agentTaskRunJsonOutput({
226280
success: true,
227281
schema: "wp-codebox/agent-task-run/v1",

0 commit comments

Comments
 (0)