Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/runtime-core/src/headless-agent-task-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AGENT_TASK_RUN_RESULT_SCHEMA, type AgentTaskRunResultSummary } from "./
import { RUNTIME_PROFILE_SCHEMA, normalizeRuntimeAccess, normalizeRuntimeProfile, type RuntimeAccess, type RuntimeProfile } from "./runtime-boundary-contracts.js"
import { TASK_INPUT_JSON_SCHEMA, normalizeTaskInput, type TaskInput, type TaskInputRequest } from "./task-input.js"
import { isPlainObject, objectValue, stringList, stringValue, stripUndefined } from "./object-utils.js"
import { WORKSPACE_DELTA_JSON_SCHEMA, workspaceDeltaFromAgentTaskRunResult, type WorkspaceDelta } from "./workspace-delta.js"

export const HEADLESS_AGENT_TASK_REQUEST_SCHEMA = "wp-codebox/headless-agent-task-request/v1" as const
export const HEADLESS_AGENT_TASK_RESULT_SCHEMA = "wp-codebox/headless-agent-task-result/v1" as const
Expand Down Expand Up @@ -34,6 +35,7 @@ export interface HeadlessAgentTaskResult {
refs: AgentTaskRunResultSummary["refs"]
artifacts: AgentTaskRunResultSummary["artifacts"]
evidence_refs: AgentTaskRunResultSummary["refs"]["evidence_bundles"]
workspace_delta: WorkspaceDelta
diagnostics: Array<Record<string, unknown>>
metadata: Record<string, unknown>
agent_task_run_result: AgentTaskRunResultSummary
Expand Down Expand Up @@ -69,7 +71,7 @@ export const HEADLESS_AGENT_TASK_REQUEST_JSON_SCHEMA = {
export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = {
$id: HEADLESS_AGENT_TASK_RESULT_SCHEMA,
type: "object",
required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "diagnostics", "metadata", "agent_task_run_result"],
required: ["schema", "success", "status", "summary", "refs", "artifacts", "evidence_refs", "workspace_delta", "diagnostics", "metadata", "agent_task_run_result"],
additionalProperties: false,
properties: {
schema: { type: "string", const: HEADLESS_AGENT_TASK_RESULT_SCHEMA },
Expand All @@ -80,6 +82,7 @@ export const HEADLESS_AGENT_TASK_RESULT_JSON_SCHEMA = {
refs: { type: "object" },
artifacts: { type: "array", items: { type: "object" } },
evidence_refs: { type: "array", items: { type: "object" } },
workspace_delta: WORKSPACE_DELTA_JSON_SCHEMA,
diagnostics: { type: "array", items: { type: "object" } },
metadata: { type: "object" },
agent_task_run_result: { type: "object", properties: { schema: { type: "string", const: AGENT_TASK_RUN_RESULT_SCHEMA } } },
Expand Down Expand Up @@ -124,6 +127,7 @@ export function headlessAgentTaskRequestToRunInput(request: HeadlessAgentTaskReq
}

export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSummary, metadata: Record<string, unknown> = {}): HeadlessAgentTaskResult {
const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(result)
return {
schema: HEADLESS_AGENT_TASK_RESULT_SCHEMA,
success: result.success,
Expand All @@ -133,7 +137,8 @@ export function normalizeHeadlessAgentTaskResult(result: AgentTaskRunResultSumma
refs: result.refs,
artifacts: result.artifacts,
evidence_refs: result.refs.evidence_bundles,
diagnostics: result.diagnostics,
workspace_delta: workspaceDelta,
diagnostics: result.diagnostics.concat(workspaceDelta.diagnostics),
metadata: stripUndefined({ ...metadata, ...result.metadata }),
agent_task_run_result: result,
}
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export * from "./structured-artifacts.js"
export * from "./tool-call-artifacts.js"
export * from "./agent-task-run-result.js"
export * from "./headless-agent-task-contracts.js"
export * from "./workspace-delta.js"
export * from "./status-taxonomy.js"
export * from "./agent-terminal-result.js"
export * from "./agent-runtime-workload.js"
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export * from "./task-input.js"
export * from "./tool-call-artifacts.js"
export * from "./transfer-proof.js"
export * from "./workspace-policy.js"
export * from "./workspace-delta.js"
export * from "./workspace-preload-artifacts.js"
export * from "./wordpress-crud-contracts.js"
export * from "./wordpress-block-exercise-contracts.js"
Expand Down
134 changes: 134 additions & 0 deletions packages/runtime-core/src/workspace-delta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { safeArtifactRelativePath } from "./artifact-paths.js"
import type { AgentTaskRunArtifactRef, AgentTaskRunResultSummary } from "./agent-task-run-result.js"
import { stripUndefined } from "./object-utils.js"

export const WORKSPACE_DELTA_SCHEMA = "wp-codebox/workspace-delta/v1" as const

export interface WorkspaceDeltaArtifactRef {
kind: "codebox-changed-files" | "codebox-patch"
path: string
sha256?: string
size_bytes?: number
}

export interface WorkspaceDeltaDiagnostic extends Record<string, unknown> {
code: "workspace_delta.artifact_not_portable" | "workspace_delta.incomplete"
message: string
}

export interface WorkspaceDelta {
schema: typeof WORKSPACE_DELTA_SCHEMA
status: "changed" | "no_op" | "unavailable"
changed_files_count?: number
patch_bytes?: number
changed_files?: WorkspaceDeltaArtifactRef
patch?: WorkspaceDeltaArtifactRef
diagnostics: WorkspaceDeltaDiagnostic[]
}

export const WORKSPACE_DELTA_JSON_SCHEMA = {
$id: WORKSPACE_DELTA_SCHEMA,
type: "object",
required: ["schema", "status", "diagnostics"],
additionalProperties: false,
properties: {
schema: { const: WORKSPACE_DELTA_SCHEMA },
status: { enum: ["changed", "no_op", "unavailable"] },
changed_files_count: { type: "integer", minimum: 0 },
patch_bytes: { type: "integer", minimum: 0 },
changed_files: workspaceDeltaArtifactJsonSchema("codebox-changed-files"),
patch: workspaceDeltaArtifactJsonSchema("codebox-patch"),
diagnostics: {
type: "array",
items: {
type: "object",
required: ["code", "message"],
additionalProperties: false,
properties: {
code: { enum: ["workspace_delta.artifact_not_portable", "workspace_delta.incomplete"] },
message: { type: "string" },
},
},
},
},
} as const

/**
* Produces the Codebox-owned change handoff. Consumers apply it; Codebox never
* exposes the sandbox's host paths as part of that handoff.
*/
export function workspaceDeltaFromAgentTaskRunResult(result: AgentTaskRunResultSummary): WorkspaceDelta {
const changedFiles = portableWorkspaceArtifact(result.refs.changed_files[0], "codebox-changed-files")
const patch = portableWorkspaceArtifact(result.refs.patches[0], "codebox-patch")
const diagnostics: WorkspaceDeltaDiagnostic[] = []

for (const artifact of [changedFiles, patch]) {
if (artifact.diagnostic) diagnostics.push(artifact.diagnostic)
}

if (result.no_op.detected) {
return { schema: WORKSPACE_DELTA_SCHEMA, status: "no_op", changed_files_count: 0, patch_bytes: 0, diagnostics }
}

if (!changedFiles.ref || !patch.ref) {
diagnostics.push({
code: "workspace_delta.incomplete",
message: "Workspace delta requires portable changed-files and patch artifacts.",
})
return { schema: WORKSPACE_DELTA_SCHEMA, status: "unavailable", diagnostics }
}

return stripUndefined({
schema: WORKSPACE_DELTA_SCHEMA,
status: "changed" as const,
changed_files_count: nonNegativeInteger(result.no_op.changed_files_count),
patch_bytes: nonNegativeInteger(result.no_op.patch_bytes),
changed_files: changedFiles.ref,
patch: patch.ref,
diagnostics,
}) as WorkspaceDelta
}

function portableWorkspaceArtifact(artifact: AgentTaskRunArtifactRef | undefined, kind: WorkspaceDeltaArtifactRef["kind"]): { ref?: WorkspaceDeltaArtifactRef; diagnostic?: WorkspaceDeltaDiagnostic } {
if (!artifact?.path) return {}

try {
const path = artifact.path.trim().replace(/\\/g, "/")
if (path.startsWith("/") || /^[A-Za-z]:($|\/)/.test(path)) {
throw new Error("Workspace delta artifact paths must be relative.")
}
return {
ref: stripUndefined({
kind,
path: safeArtifactRelativePath(path),
sha256: artifact.sha256,
size_bytes: nonNegativeInteger(artifact.size_bytes),
}) as WorkspaceDeltaArtifactRef,
}
} catch {
return {
diagnostic: {
code: "workspace_delta.artifact_not_portable",
message: `Codebox emitted a non-portable ${kind} artifact reference.`,
},
}
}
}

function workspaceDeltaArtifactJsonSchema(kind: WorkspaceDeltaArtifactRef["kind"]) {
return {
type: "object",
required: ["kind", "path"],
additionalProperties: false,
properties: {
kind: { const: kind },
path: { type: "string", minLength: 1 },
sha256: { type: "string", minLength: 1 },
size_bytes: { type: "integer", minimum: 0 },
},
}
}

function nonNegativeInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined
}
56 changes: 55 additions & 1 deletion tests/agent-task-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync }
import { tmpdir } from "node:os"
import { join } from "node:path"
import { chdir, cwd } from "node:process"
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"
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"
import { effectivePolicyCommands } from "../packages/runtime-core/src/contracts.js"
import { commandCatalogOutput } from "../packages/cli/src/commands/discovery.js"
import { agentTaskResultFromRun, agentTaskRunExitCode, agentTaskRunJsonOutput, normalizeAgentTaskRunCliInput, writeAgentTaskRunResultFile } from "../packages/cli/src/commands/agent-task-run.js"
Expand Down Expand Up @@ -222,6 +222,60 @@ assert.equal(headlessResult.preview?.public_url, "https://preview.example.test/"
assert.equal(headlessResult.evidence_refs[0].kind, "codebox-evidence-bundle")
assert.equal(headlessResult.agent_task_run_result.schema, AGENT_TASK_RUN_RESULT_SCHEMA)

const workspaceDeltaResult = normalizeAgentTaskRunResult({
success: true,
artifacts: [
{ kind: "codebox-changed-files", path: "files/changed-files.json", bytes: 128, sha256: "changed" },
{ kind: "codebox-patch", path: "files/patch.diff", bytes: 256, sha256: "patch" },
],
agent_result: {
changedFiles: { artifact: "files/changed-files.json", bytes: 128, count: 1, sha256: "changed" },
patch: { artifact: "files/patch.diff", bytes: 256, sha256: "patch" },
},
})
const workspaceDelta = workspaceDeltaFromAgentTaskRunResult(workspaceDeltaResult)
assert.equal(workspaceDelta.schema, WORKSPACE_DELTA_SCHEMA)
assert.equal(workspaceDelta.status, "changed")
assert.equal(workspaceDelta.changed_files?.path, "files/changed-files.json")
assert.equal(workspaceDelta.patch?.path, "files/patch.diff")
assert.equal(normalizeHeadlessAgentTaskResult(workspaceDeltaResult).workspace_delta.status, "changed")
assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.additionalProperties, false)
assert.equal(WORKSPACE_DELTA_JSON_SCHEMA.properties.changed_files.required.includes("path"), true)

assert.equal(workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({ success: true, no_op: true })).status, "no_op")

const hostPathDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
success: true,
artifacts: [
{ kind: "codebox-changed-files", path: "/private/codebox/files/changed-files.json" },
{ kind: "codebox-patch", path: "/private/codebox/files/patch.diff" },
],
}))
assert.equal(hostPathDelta.status, "unavailable")
assert.equal(hostPathDelta.diagnostics.filter((diagnostic) => diagnostic.code === "workspace_delta.artifact_not_portable").length, 2)

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"]) {
const malformedDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
success: true,
artifacts: [
{ kind: "codebox-changed-files", path },
{ kind: "codebox-patch", path: "files/patch.diff" },
],
}))
assert.equal(malformedDelta.status, "unavailable")
assert.equal(malformedDelta.changed_files, undefined)
assert.equal(JSON.stringify(malformedDelta).includes("private/codebox"), false, "workspace delta never leaks a host artifact path")
}

const inferredFilenameDelta = workspaceDeltaFromAgentTaskRunResult(normalizeAgentTaskRunResult({
success: true,
artifacts: [
{ kind: "artifact", path: "files/changed-files.json" },
{ kind: "artifact", path: "files/patch.diff" },
],
}))
assert.equal(inferredFilenameDelta.status, "unavailable", "workspace delta only accepts explicitly typed artifacts")

const headlessJsonOutput = agentTaskRunJsonOutput({
success: true,
schema: "wp-codebox/agent-task-run/v1",
Expand Down
Loading