Skip to content

Commit 03e6019

Browse files
authored
Merge pull request #850 from Automattic/fix/issue-845-agent-task-patch-materialization
Capture raw sandbox input mount diffs
2 parents 0bd8590 + da34769 commit 03e6019

5 files changed

Lines changed: 90 additions & 12 deletions

File tree

packages/cli/src/agent-code.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function agentChatTaskCode(options: AgentSandboxCodeOptions): string {
9494
const timeoutLimit = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 0
9595
const agentBundles = normalizeAgentBundleSpecs(options.agentBundles ?? [])
9696
const runtimeTask = normalizeRuntimeTask(options.runtimeTask, input)
97+
const sandboxWorkspaceJson = JSON.stringify(sandboxWorkspace ?? null)
9798

9899
return `
99100
if (function_exists('wp_set_current_user')) {
@@ -119,19 +120,35 @@ add_filter('datamachine_code_remote_workspace_backend_should_handle', '__return_
119120
120121
$sandbox_workspace_adoptions = array();
121122
if (function_exists('wp_get_ability')) {
122-
$sandbox_adopt_callback = static function () use (&$sandbox_workspace_adoptions): void {
123+
$sandbox_workspace_contract = json_decode(${JSON.stringify(sandboxWorkspaceJson)}, true);
124+
$sandbox_repo_backed_mounts = array();
125+
if (is_array($sandbox_workspace_contract) && is_array($sandbox_workspace_contract['mounts'] ?? null)) {
126+
foreach ($sandbox_workspace_contract['mounts'] as $sandbox_mount) {
127+
if (!is_array($sandbox_mount)) {
128+
continue;
129+
}
130+
$sandbox_mount_target = rtrim((string) ($sandbox_mount['target'] ?? ''), '/');
131+
if ('' === $sandbox_mount_target) {
132+
continue;
133+
}
134+
if ('repo-backed' === (string) ($sandbox_mount['sourceMode'] ?? '')) {
135+
$sandbox_repo_backed_mounts[$sandbox_mount_target] = true;
136+
}
137+
}
138+
}
139+
$sandbox_adopt_callback = static function () use (&$sandbox_workspace_adoptions, $sandbox_repo_backed_mounts): void {
123140
$sandbox_adopt_ability = wp_get_ability('datamachine-code/workspace-adopt') ?: wp_get_ability('datamachine/workspace-adopt');
124141
if (!$sandbox_adopt_ability || !method_exists($sandbox_adopt_ability, 'execute')) {
125142
return;
126143
}
127144
foreach (glob(rtrim(DATAMACHINE_WORKSPACE_PATH, '/') . '/*', GLOB_ONLYDIR) ?: array() as $sandbox_workspace_dir) {
128145
$sandbox_workspace_name = basename($sandbox_workspace_dir);
129-
if (is_file($sandbox_workspace_dir . '/.git')) {
146+
if (is_file($sandbox_workspace_dir . '/.git') || isset($sandbox_repo_backed_mounts[rtrim($sandbox_workspace_dir, '/')])) {
130147
$sandbox_workspace_adoptions[$sandbox_workspace_name] = array(
131148
'success' => true,
132149
'skipped' => true,
133-
'reason' => 'linked_worktree_mount',
134-
'message' => 'Mounted linked worktrees are treated as sandbox workspaces, not Data Machine primary checkouts.',
150+
'reason' => 'repo_backed_mount',
151+
'message' => 'Mounted repo-backed workspaces are treated as sandbox workspaces, not Data Machine primary checkouts.',
135152
);
136153
continue;
137154
}

packages/cli/src/commands/recipe-run.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createHash } from "node:crypto"
2-
import { readdir, readFile, stat, writeFile } from "node:fs/promises"
2+
import { cp, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
34
import { basename, dirname, join, resolve } from "node:path"
4-
import { DEFAULT_WORDPRESS_VERSION, artifactBundleRunRef, artifactManifestFile, createBenchResultsJsonSchema, createRuntime, refreshArtifactManifestFileSha256s, stripUndefined, upsertArtifactManifestFiles, type ArtifactBundle, type ArtifactManifest, type ArtifactManifestFile, type BenchmarkArtifactRef, type BenchResults, type ExecutionResult, type Runtime, type RuntimeAssetSpec, type RuntimePreviewSpec, type RuntimeRunRecord, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
5+
import { DEFAULT_WORDPRESS_VERSION, artifactBundleRunRef, artifactManifestFile, createBenchResultsJsonSchema, createRuntime, refreshArtifactManifestFileSha256s, stripUndefined, upsertArtifactManifestFiles, type ArtifactBundle, type ArtifactManifest, type ArtifactManifestFile, type BenchmarkArtifactRef, type BenchResults, type ExecutionResult, type Runtime, type RuntimeAssetSpec, type RuntimePreviewSpec, type RuntimeRunRecord, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeDeclaredArtifact, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe, type WorkspaceRecipeProbe, type WorkspaceRecipeSiteSeed } from "@automattic/wp-codebox-core"
56
import { createPlaygroundRuntimeBackend } from "@automattic/wp-codebox-playground"
67
import { Ajv2020 } from "ajv/dist/2020.js"
78
import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js"
@@ -125,6 +126,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
125126
let dependencyOverlays: PreparedDependencyOverlay[] = []
126127
let stagedFiles: PreparedStagedFile[] = []
127128
let overlays: PreparedRuntimeOverlay[] = []
129+
const inputMountBaselinePaths: string[] = []
128130
let backendPackage: PreparedRuntimeBackendPackage | undefined
129131
let runtime: Awaited<ReturnType<typeof createRuntime>> | undefined
130132
const executions: RecipeExecutionResult[] = []
@@ -308,12 +310,13 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
308310

309311
for (const mount of recipe.inputs?.mounts ?? []) {
310312
const source = resolve(recipeDirectory, mount.source)
313+
const metadata = await inputMountMetadataWithBaseline(source, mount, inputMountBaselinePaths)
311314
await awaitRecipe(`input.mount:${mount.target}`, runtime.mount({
312315
type: await recipeMountType(source, mount.type),
313316
source,
314317
target: mount.target,
315318
mode: mount.mode ?? "readwrite",
316-
metadata: mount.metadata,
319+
metadata,
317320
}))
318321
interruption?.throwIfInterrupted()
319322
}
@@ -416,6 +419,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
416419
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
417420
await awaitRecipe("runtime.release", releaseRuntime(activeRuntime, successfulRecipe ? options.previewHoldSeconds : 0))
418421
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
422+
await cleanupInputMountBaselines(inputMountBaselinePaths)
419423
})
420424
runRecord = await runRegistry.read(runRecord.runId)
421425
interruption?.throwIfInterrupted()
@@ -529,7 +533,10 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
529533
}
530534
}
531535

532-
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, () => cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays))
536+
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
537+
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
538+
await cleanupInputMountBaselines(inputMountBaselinePaths)
539+
})
533540
runRecord = await runRegistry.read(runRecord.runId)
534541
const serializedError = interruption?.metadata ? recipeInterruptionSerializedError(interruption.metadata) : serializeRecipeRunError(error)
535542
runRecord = await runRegistry.update(runRecord.runId, {
@@ -1644,6 +1651,59 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac
16441651
}
16451652
}
16461653

1654+
async function inputMountMetadataWithBaseline(source: string, mount: WorkspaceRecipeMount, cleanupPaths: string[]): Promise<Record<string, unknown> | undefined> {
1655+
const metadata = mount.metadata ? { ...mount.metadata } : {}
1656+
if ((mount.mode ?? "readwrite") !== "readwrite") {
1657+
return Object.keys(metadata).length > 0 ? metadata : undefined
1658+
}
1659+
if (typeof metadata.baselineSource === "string" && metadata.baselineSource.length > 0) {
1660+
return metadata
1661+
}
1662+
1663+
let sourceStats
1664+
try {
1665+
sourceStats = await stat(source)
1666+
} catch {
1667+
return Object.keys(metadata).length > 0 ? metadata : undefined
1668+
}
1669+
if (!sourceStats.isDirectory() || await hasGitMetadata(source)) {
1670+
return Object.keys(metadata).length > 0 ? metadata : undefined
1671+
}
1672+
1673+
const baselineSource = await mkdtemp(join(tmpdir(), "wp-codebox-input-mount-baseline-"))
1674+
cleanupPaths.push(baselineSource)
1675+
await cp(source, baselineSource, {
1676+
recursive: true,
1677+
filter: (entry) => shouldCopyInputMountBaselineEntry(source, entry),
1678+
})
1679+
metadata.baselineSource = baselineSource
1680+
metadata.baselineStrategy = "input-mount-snapshot"
1681+
return metadata
1682+
}
1683+
1684+
async function cleanupInputMountBaselines(paths: string[]): Promise<void> {
1685+
await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })))
1686+
paths.length = 0
1687+
}
1688+
1689+
async function hasGitMetadata(directory: string): Promise<boolean> {
1690+
try {
1691+
await stat(join(directory, ".git"))
1692+
return true
1693+
} catch {
1694+
return false
1695+
}
1696+
}
1697+
1698+
function shouldCopyInputMountBaselineEntry(sourceRoot: string, entry: string): boolean {
1699+
const relativePath = entry.slice(sourceRoot.length).replace(/^\/+/, "")
1700+
if (!relativePath) {
1701+
return true
1702+
}
1703+
const firstSegment = relativePath.split("/")[0]
1704+
return firstSegment !== ".git" && firstSegment !== "node_modules"
1705+
}
1706+
16471707
function recipeRunDependencyOverlay(overlay: PreparedDependencyOverlay): Record<string, unknown> {
16481708
return {
16491709
source: overlay.source,

packages/runtime-playground/src/artifacts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ interface RedactionResult {
152152

153153
export const MAX_CAPTURED_MOUNT_FILES = 200
154154
export const MAX_CAPTURED_MOUNT_FILE_BYTES = 1024 * 1024
155-
export const SKIPPED_CAPTURE_DIRECTORIES = new Set([".git", "node_modules"])
155+
export const SKIPPED_CAPTURE_DIRECTORIES = new Set([".git", "node_modules", "target"])
156156

157157
const packageRequire = createRequire(import.meta.url)
158158

packages/runtime-playground/src/mount-materialization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export function vfsMountSnapshotPhp(hostSnapshots: HostMountSnapshot[]): string
116116
const payload = JSON.stringify(JSON.stringify({ mounts: hostSnapshots }))
117117
return `<?php
118118
$payload = json_decode(${payload}, true);
119-
$skip = array_fill_keys(array('.git', 'node_modules'), true);
119+
$skip = array_fill_keys(array('.git', 'node_modules', 'target'), true);
120120
121121
function wp_codebox_vfs_mount_files(string $root, array $host_hashes, array $skip): array {
122122
$files = array();

scripts/agent-sandbox-code-smoke.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ async function main() {
107107
assert.doesNotMatch(code, /Bounded tree/, "sandbox chat should not inject Data Machine directive tree output")
108108
assert.match(code, /datamachine_code_remote_workspace_backend_should_handle/, "sandbox mode should use the mounted workspace backend")
109109
assert.match(code, /is_file\(\$sandbox_workspace_dir \. '\/.git'\)/, "sandbox setup should detect linked worktree mounts")
110-
assert.match(code, /linked_worktree_mount/, "sandbox setup should report linked worktree mounts as non-fatal diagnostics")
111-
assert.ok(code.indexOf("linked_worktree_mount") < code.indexOf("$sandbox_adopt_result = $sandbox_adopt_ability->execute"), "linked worktree mounts should be skipped before Data Machine workspace adoption")
110+
assert.match(code, /\$sandbox_repo_backed_mounts/, "sandbox setup should track repo-backed mounts from the workspace contract")
111+
assert.match(code, /repo_backed_mount/, "sandbox setup should report repo-backed mounts as non-fatal diagnostics")
112+
assert.ok(code.indexOf("repo_backed_mount") < code.indexOf("$sandbox_adopt_result = $sandbox_adopt_ability->execute"), "repo-backed mounts should be skipped before Data Machine workspace adoption")
112113
assert.match(code, /\\"tool_policy\\":\{\\"mode\\":\\"allow\\",\\"tools\\":\[\\"workspace_read\\",\\"workspace_write\\",\\"workspace_edit\\"\]/, "sandbox agent tool policy should include only sandbox-visible runtime tool ids")
113114
assert.match(code, /wp_codebox_import_sandbox_agent_bundles/, "sandbox setup should import declared runtime agent bundles")
114115
assert.match(code, /wp_agent_import_runtime_bundles/, "sandbox setup should consume the generic runtime bundle helper")

0 commit comments

Comments
 (0)