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:
parent
677242344c
commit
d5208d30c1
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue