diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index dfd16318ba..242ca77995 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -3272,7 +3272,8 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = // A codex bring-up over the remote sandbox lane crosses all 7 boundaries. // Each boundary span parents to the sandbox bring-up span, not to the run - // root or the turn span. + // root or the turn span. The `stage.sync` step also opens one host `pack` + // span around the workspace tarball build, so it nests one level deeper. const childNames = spans .filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan) .map((span) => span.name) @@ -3283,15 +3284,28 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = "bridge.paperclip", "bridge.process-session", "codex-home.seed", + "pack", "skills.reconcile", "stage.sync", "workspace.resolve", ], ); - // Every boundary span parents to the sandbox bring-up span and ends. + // The host `pack` span nests under the `stage.sync` step span (the host + // tarball build runs inside that step), not directly under the bring-up + // span. + const stageSyncSpan = spans.find((span) => span.name === "stage.sync"); + const packSpan = spans.find((span) => span.name === "pack"); + expect(stageSyncSpan).toBeTruthy(); + expect(packSpan).toBeTruthy(); + expect(packSpan!.parent).toBe(stageSyncSpan); + expect(packSpan!.ended).toBe(true); + + // Every boundary step span parents to the sandbox bring-up span and ends. + // The `pack` span is the one exception: it parents to `stage.sync` above. for (const span of spans) { if (span === runRootSpan || span === startupSpan || span === turnSpan) continue; + if (span === packSpan) continue; expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan); expect(span.ended, `span "${span.name}" must end`).toBe(true); } diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 9a5a613a63..3dc9fa7260 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -86,6 +86,7 @@ import { import { createRuntimeSpanRunner, emitSkippedStartupStep, + getActiveStepContext, measureStartupStep, NOOP_STARTUP_SPAN, NOOP_STARTUP_TRACE_CONTEXT, @@ -1332,6 +1333,11 @@ async function stageAcpRemoteRuntime(input: { additionalSources?: SandboxAdditionalSource[]; onLog: AdapterExecutionContext["onLog"]; onRuntimeProgress: AdapterExecutionContext["onRuntimeProgress"]; + // Optional host span runner for the workspace tarball build. It rides down to + // prepareSandboxManagedRuntime so the host pack time shows as one `pack` span. + // The caller passes a runner that parents to the active `stage.sync` step, so + // the `pack` span nests under `stage.sync`. The default is a no-op. + runtimeSpan?: RuntimeSpanRunner; }): Promise { await input.onLog( "stdout", @@ -1350,6 +1356,7 @@ async function stageAcpRemoteRuntime(input: { : {}), onProgress: (line) => input.onLog("stdout", line), onRuntimeProgress: input.onRuntimeProgress, + runtimeSpan: input.runtimeSpan, }); } @@ -1375,6 +1382,12 @@ async function buildRuntime(input: { // span per unit of work. The run closure passes the run-scoped runner here; // when it is absent, each bridge site opens no wrapper span. runtimeSpan?: RuntimeSpanRunner; + // Wrap the host workspace tarball build in one `pack` span. Unlike + // `runtimeSpan`, this runner parents each span to the active startup step, so + // the `pack` span nests under the `stage.sync` step that runs the staging + // seam. `buildRuntime` threads it into the staging seam. When it is absent, the + // staging seam opens no `pack` span. + stageRuntimeSpan?: RuntimeSpanRunner; }): Promise { const { runId, agent, config, context, authToken } = input.ctx; // Injectable monotonic clock for per-step startup timing. Hoisted above the @@ -1865,6 +1878,7 @@ async function buildRuntime(input: { additionalSources, onLog: input.ctx.onLog, onRuntimeProgress: input.ctx.onRuntimeProgress, + runtimeSpan: input.stageRuntimeSpan, }); // Snapshot env before the seam so we can capture exactly which keys it // repointed onto the in-sandbox home (e.g. `CODEX_HOME`) and replay them @@ -3155,6 +3169,16 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // `getRuntimeParentContext`, so a wrapper span always parents to the current // run span. On a no-op trace context the runner opens no real span. const runRuntimeSpan = createRuntimeSpanRunner(tracing, getRuntimeParentContext); + // Wrap the host workspace tarball build in one `pack` span. This runner + // parents each span to the ACTIVE startup step (not the run span), so the + // `pack` span nests under the `stage.sync` step that runs the staging seam. + // The staging seam runs inside `stage.sync`'s measured step, so + // `getActiveStepContext()` returns that step's child context at pack time. + // On a no-op trace context the runner opens no real span. + const runStageSpan = createRuntimeSpanRunner( + tracing, + () => getActiveStepContext()?.parentContext, + ); // `runFailed` marks the run root span status at end time. It stays `true` // until the run reaches a clean completed turn, so every failure and every // early exit closes the span with error status. @@ -3214,7 +3238,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // parents to its step span. On a local or SSH target // `spanParent.parentContext` is a no-op token, so the wrap is inert. prepared = await runWithRuntimeParent(spanParent.parentContext, () => - buildRuntime({ ctx, engine, deps, spanParent, getRuntimeParentContext, runtimeSpan: runRuntimeSpan }), + buildRuntime({ ctx, engine, deps, spanParent, getRuntimeParentContext, runtimeSpan: runRuntimeSpan, stageRuntimeSpan: runStageSpan }), ); } catch (err) { rootSpan.end(true); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 5688b4f194..32e459a21f 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -16,6 +16,7 @@ import { import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; import type { RunProcessResult } from "./server-utils.js"; import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js"; +import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; export interface CommandManagedRuntimeRunner { /** @@ -478,6 +479,10 @@ export async function prepareCommandManagedRuntime(input: { // task wires it into the byte-counting writeFile/readFile transport. onProgress?: RuntimeProgressSink; onRuntimeProgress?: RuntimeStatusSink; + // Optional host span runner for the workspace tarball build. Forwarded to + // prepareSandboxManagedRuntime so the host pack time rides one `pack` span + // under the `stage.sync` step. The default is a no-op. + runtimeSpan?: RuntimeSpanRunner; }): Promise { const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000; const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; @@ -529,6 +534,7 @@ export async function prepareCommandManagedRuntime(input: { additionalSources: input.additionalSources, onProgress: input.onProgress, onRuntimeProgress: input.onRuntimeProgress, + runtimeSpan: input.runtimeSpan, }); } } @@ -567,5 +573,6 @@ export async function prepareCommandManagedRuntime(input: { additionalSources: input.additionalSources, onProgress: input.onProgress, onRuntimeProgress: input.onRuntimeProgress, + runtimeSpan: input.runtimeSpan, }); } diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 90989c6f9f..a525b2f290 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1150,6 +1150,11 @@ export async function prepareAdapterExecutionTargetRuntime(input: { // counters without further changes here. onProgress?: RuntimeProgressSink; onRuntimeProgress?: RuntimeStatusSink; + // Optional host span runner for the workspace tarball build. Only the confined + // sandbox lane uses it: it forwards the runner to prepareCommandManagedRuntime + // so the host pack time rides one `pack` span under the `stage.sync` step. The + // SSH and local lanes ignore it. The default is a no-op. + runtimeSpan?: RuntimeSpanRunner; }): Promise { const target = input.target ?? { kind: "local" as const }; if (target.kind === "local") { @@ -1213,6 +1218,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { detectCommand: input.detectCommand, onProgress: input.onProgress, onRuntimeProgress: input.onRuntimeProgress, + runtimeSpan: input.runtimeSpan, }); return { target, diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 4e82cb0d86..52c3cb65db 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -19,6 +19,15 @@ import { prepareCommandManagedRuntime, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; +import { + createRuntimeSpanRunner, + getActiveStepContext, + measureStartupStep, + type RuntimeSpanRunner, + type StartupSpan, + type StartupTraceContext, + type StartupTracer, +} from "./acpx-engine/startup-timing.js"; import type { RunProcessResult } from "./server-utils.js"; function toArrayBuffer(bytes: Buffer): ArrayBuffer { @@ -156,6 +165,87 @@ async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Pro return stdout.split("\n").map((line) => line.trim()).filter(Boolean); } +// Build a filesystem-backed managed-runtime client. The host tarball path runs +// unchanged; the client just materializes the mappings on the local disk, so a +// pack-span test needs no provider. +function makeFilesystemClient(): SandboxManagedRuntimeClient { + const client: SandboxManagedRuntimeClient = { + makeDir: async (remotePath) => { + await mkdir(remotePath, { recursive: true }); + }, + writeFile: async (remotePath, bytes) => { + await mkdir(path.dirname(remotePath), { recursive: true }); + await writeFile(remotePath, Buffer.from(bytes)); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async (remotePath) => { + const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); + }, + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; + attachFallbackSyncIn(client); + return client; +} + +// One recorded span from the fake tracer. `parentName` is the name of the span +// that the start context carried, so a test can assert the parent relationship. +interface RecordedSpan { + name: string; + parentName: string | null; + ended: boolean; + attributes: Record; +} + +// A fake trace context that records every span and its parent by name. It +// satisfies the structural `StartupTraceContext` contract, so the real +// `createRuntimeSpanRunner` and `measureStartupStep` drive it unchanged. The +// opaque parent token is the parent's `RecordedSpan`, so a child span reads its +// parent name from the start context. +function createRecordingTraceContext(): { + traceContext: StartupTraceContext; + spans: RecordedSpan[]; +} { + const spans: RecordedSpan[] = []; + const byHandle = new WeakMap(); + const tracer: StartupTracer = { + startSpan(name, options, context) { + const parent = context as RecordedSpan | undefined; + const record: RecordedSpan = { + name, + parentName: parent?.name ?? null, + ended: false, + attributes: { ...(options?.attributes ?? {}) }, + }; + spans.push(record); + const handle: StartupSpan = { + setAttribute(key, value) { + record.attributes[key] = value; + }, + setStatus() {}, + end() { + record.ended = true; + }, + }; + byHandle.set(handle, record); + return handle; + }, + }; + const traceContext: StartupTraceContext = { + tracer, + contextWithSpan: (span) => byHandle.get(span), + }; + return { traceContext, spans }; +} + describe("sandbox managed runtime", () => { const cleanupDirs: string[] = []; @@ -1929,4 +2019,106 @@ describe("sandbox managed runtime", () => { else process.env[flagKey] = priorFlag; } }); + + it("builds the workspace tarball inside one host pack span for a usual workspace sync", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-span-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8"); + + // Record every span name the runner opens and run the wrapped work, so the + // test proves the host opens exactly one `pack` span around the tarball + // build for the usual (plain) workspace sync. + const openedSpans: string[] = []; + const runtimeSpan: RuntimeSpanRunner = async (name, work) => { + openedSpans.push(name); + return await work(); + }; + + const prepared = await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-pack", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client: makeFilesystemClient(), + workspaceLocalDir: localWorkspaceDir, + runtimeSpan, + }); + + expect(openedSpans).toEqual(["pack"]); + // The tarball build still lands the workspace inside the span, so the wrap + // changes no staging behavior. + await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("workspace body\n"); + expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); + }); + + it("nests the host pack span under the stage.sync step span", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-nest-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(localWorkspaceDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8"); + + const { traceContext, spans } = createRecordingTraceContext(); + // The root span stands in for `sandbox.startup`. Its child context is the + // step span's parent, exactly as the executor wires it. + const rootHandle = traceContext.tracer.startSpan("sandbox.startup", undefined, undefined); + const rootContext = traceContext.contextWithSpan(rootHandle); + + // The stage runner parents each span to the ACTIVE startup step, so the + // `pack` span nests under `stage.sync`. This is the exact runner the + // executor threads into the staging seam. + const stageRuntimeSpan = createRuntimeSpanRunner( + traceContext, + () => getActiveStepContext()?.parentContext, + ); + + // A deterministic monotonic clock, so the step timing stays test-stable. + let clock = 0; + const now = () => (clock += 1000); + + await measureStartupStep( + {}, + now, + "stage.sync", + async () => { + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-nest", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client: makeFilesystemClient(), + workspaceLocalDir: localWorkspaceDir, + runtimeSpan: stageRuntimeSpan, + }); + }, + { + tracer: traceContext.tracer, + parentContext: rootContext, + contextWithSpan: (span) => traceContext.contextWithSpan(span), + }, + ); + + const stageSpan = spans.find((span) => span.name === "stage.sync"); + const packSpan = spans.find((span) => span.name === "pack"); + expect(stageSpan).toBeDefined(); + expect(packSpan).toBeDefined(); + expect(packSpan!.ended).toBe(true); + // The `pack` span parents to `stage.sync`, not to the root span, so it nests + // under the step in a real trace. + expect(packSpan!.parentName).toBe("stage.sync"); + }); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 2f9db88dc8..4169ced4a7 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -27,6 +27,7 @@ import { type RuntimeStatusSink, } from "./runtime-progress.js"; import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js"; +import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; const execFile = promisify(execFileCallback); const SANDBOX_WORKSPACE_HEAVY_DIR_NAMES = [ @@ -703,6 +704,12 @@ export async function prepareSandboxManagedRuntime(input: { // child task wires it into writeFile/readFile. onProgress?: RuntimeProgressSink; onRuntimeProgress?: RuntimeStatusSink; + // Optional host span runner for the workspace tarball build. When present, the + // host builds both workspace tarballs inside one span named `pack`, so the + // host pack time is visible under the `stage.sync` step. The default is a + // no-op that keeps the current behavior and control flow. A throwing runner + // never changes control flow (see `createRuntimeSpanRunner`). + runtimeSpan?: RuntimeSpanRunner; }): Promise { const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); @@ -835,73 +842,84 @@ export async function prepareSandboxManagedRuntime(input: { const workspacePostUploadCommands: SandboxPostUploadCommand[] = []; let workspaceUploadBytes = 0; - // 1. git-history tar (git-backed workspace only). Both tar targets live under - // `runtimeRootDir` (`.paperclip-runtime/`). The git extract - // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar, - // which sits under `.paperclip-runtime`, survives to run its own extract. - if (gitSnapshot) { - await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox"); - const gitTarPath = path.join(tempDir, "git-workspace.tar"); - const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); - await withShallowGitWorkspaceClone({ - localDir: input.workspaceLocalDir, - snapshot: gitSnapshot, - }, async (cloneDir) => { - await createTarballFromDirectory({ - localDir: cloneDir, - archivePath: gitTarPath, - exclude: [".paperclip-runtime"], + // Build both host tarballs (the git-history tar and the workspace-overlay + // tar) inside one host span named `pack`. This span makes the host pack + // time visible under the `stage.sync` step, where it is otherwise a hidden + // gap with no span. The transfer (`stageConfinedSyncIn`) runs after this + // span, so `pack` measures only the host tar-build cost. The runner + // defaults to a no-op, so a caller with no injected runner keeps the + // current behavior and control flow. + const runPackSpan = (work: () => Promise): Promise => + input.runtimeSpan ? input.runtimeSpan("pack", work) : work(); + await runPackSpan(async () => { + // 1. git-history tar (git-backed workspace only). Both tar targets live under + // `runtimeRootDir` (`.paperclip-runtime/`). The git extract + // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar, + // which sits under `.paperclip-runtime`, survives to run its own extract. + if (gitSnapshot) { + await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox"); + const gitTarPath = path.join(tempDir, "git-workspace.tar"); + const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); + await withShallowGitWorkspaceClone({ + localDir: input.workspaceLocalDir, + snapshot: gitSnapshot, + }, async (cloneDir) => { + await createTarballFromDirectory({ + localDir: cloneDir, + archivePath: gitTarPath, + exclude: [".paperclip-runtime"], + }); }); + workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir }); + workspacePostUploadCommands.push({ + command: buildWorkspaceTarExtractCommand({ + workspaceRemoteDir, + remoteTar: remoteGitTar, + wipeExceptNames: [".paperclip-runtime"], + }), + }); + workspaceUploadBytes += (await fs.stat(gitTarPath)).size; + } + + // 2. workspace-overlay tar. A git-backed overlay merges on top of the just + // extracted git tree (no wipe); a plain workspace wipes every child except + // the preserved names first. The extract runs AFTER the git extract. + await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); + const workspaceTarPath = path.join(tempDir, "workspace.tar"); + const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; + if (gitSnapshot) { + await copySelectedWorkspaceEntries({ + sourceDir: input.workspaceLocalDir, + targetDir: workspaceArchiveDir, + relativePaths: gitSnapshot.overlayPaths, + exclude: workspaceArchiveExclude, + }); + } + await createTarballFromDirectory({ + localDir: workspaceArchiveDir, + archivePath: workspaceTarPath, + exclude: gitSnapshot ? undefined : workspaceArchiveExclude, }); - workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir }); + const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); + workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir }); workspacePostUploadCommands.push({ command: buildWorkspaceTarExtractCommand({ workspaceRemoteDir, - remoteTar: remoteGitTar, - wipeExceptNames: [".paperclip-runtime"], + remoteTar: remoteWorkspaceTar, + wipeExceptNames: gitSnapshot ? null : [...preservedNames], }), }); - workspaceUploadBytes += (await fs.stat(gitTarPath)).size; - } - - // 2. workspace-overlay tar. A git-backed overlay merges on top of the just - // extracted git tree (no wipe); a plain workspace wipes every child except - // the preserved names first. The extract runs AFTER the git extract. - await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); - const workspaceTarPath = path.join(tempDir, "workspace.tar"); - const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; - if (gitSnapshot) { - await copySelectedWorkspaceEntries({ - sourceDir: input.workspaceLocalDir, - targetDir: workspaceArchiveDir, - relativePaths: gitSnapshot.overlayPaths, - exclude: workspaceArchiveExclude, - }); - } - await createTarballFromDirectory({ - localDir: workspaceArchiveDir, - archivePath: workspaceTarPath, - exclude: gitSnapshot ? undefined : workspaceArchiveExclude, + // 3. Optional remove-deleted-paths command runs LAST, after both extracts. + if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) { + workspacePostUploadCommands.push({ + command: buildRemoveDeletedPathsCommand({ + remoteDir: workspaceRemoteDir, + deletedPaths: gitSnapshot.deletedPaths, + }), + }); + } + workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size; }); - const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); - workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir }); - workspacePostUploadCommands.push({ - command: buildWorkspaceTarExtractCommand({ - workspaceRemoteDir, - remoteTar: remoteWorkspaceTar, - wipeExceptNames: gitSnapshot ? null : [...preservedNames], - }), - }); - // 3. Optional remove-deleted-paths command runs LAST, after both extracts. - if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) { - workspacePostUploadCommands.push({ - command: buildRemoveDeletedPathsCommand({ - remoteDir: workspaceRemoteDir, - deletedPaths: gitSnapshot.deletedPaths, - }), - }); - } - workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size; // One confined `syncIn` for the whole merged workspace file set. The confine // guard covers every mapping BEFORE any bytes upload (fail-closed): a source