feat(adapter-utils): add host-side pack span to managed-runtime tarball build (#11072)

## Thinking Path

> - Paperclip runs AI agents through adapter execution services.
> - The adapter runtime records spans for each stage of agent startup.
> - The managed runtime packs workspace tarballs before it uploads them.
> - These pack operations had no host span, so `stage.sync` omitted pack
time.
> - This pull request adds one `pack` span around both tarball builds.
> - The span nests under the active `stage.sync` step and improves trace
detail.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The managed runtime workspace sync builds a git-history tarball and a
workspace-overlay tarball before upload.

**Subsystem affected**

The change affects `packages/adapter-utils`, which provides adapter
execution and managed runtime support.

**Current behavior**

The host builds both tarballs without an OpenTelemetry span. The
`stage.sync` trace therefore omits the host pack duration.

**Proposed behavior**

The host wraps both tarball builds in one `pack` span. The executor
parents this span under the active startup step.

**Reason and benefit**

The trace shows the time that the host spends packing workspace data.
Operators can use the existing runtime span tree to find sync delays.

**Breaking changes**

None. The default span runner remains a no-op runner, and the existing
control flow remains unchanged.

## What Changed

- Add an optional `runtimeSpan` runner to the managed runtime
preparation path.
- Create one host `pack` span around the two workspace tarball builds.
- Parent the `pack` span under the active startup step.
- Add unit coverage for span emission and span nesting.

## Verification

- Run `tsc --noEmit` for `@paperclipai/adapter-utils`.
- Run the `@paperclipai/adapter-utils` Vitest suite.
- Confirm the suite reports 448 passed tests and 4 skipped tests.
- Confirm the trace test records `pack` under `stage.sync`.

## Risks

The change adds optional tracing only. The default no-op runner
preserves behavior when tracing is not configured.

## Model Used

OpenAI Codex, GPT-5, tool use and code execution. The model reviewed the
handoff and opened this pull request. The implementation author supplied
the commit.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-07 17:47:30 -07:00 committed by GitHub
parent 677242344c
commit d5208d30c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 323 additions and 62 deletions

View File

@ -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);
}

View File

@ -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<PreparedAdapterExecutionTargetRuntime> {
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<AcpxPreparedRuntime> {
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);

View File

@ -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<PreparedSandboxManagedRuntime> {
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,
});
}

View File

@ -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<PreparedAdapterExecutionTargetRuntime> {
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,

View File

@ -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<string, string | number | boolean>;
}
// 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<StartupSpan, RecordedSpan>();
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");
});
});

View File

@ -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<PreparedSandboxManagedRuntime> {
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/<adapterKey>`). 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 = <T>(work: () => Promise<T>): Promise<T> =>
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/<adapterKey>`). 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