@tickernelz/paperclip-pro-adapter-utils 2026.1006.0 → 2026.1006.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +13 -0
  2. package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
  3. package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
  4. package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
  5. package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
  6. package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
  7. package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
  8. package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
  9. package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
  10. package/dist/acpx-engine/execute.d.ts +2 -6
  11. package/dist/acpx-engine/execute.d.ts.map +1 -1
  12. package/dist/acpx-engine/execute.js +125 -62
  13. package/dist/acpx-engine/execute.js.map +1 -1
  14. package/dist/acpx-engine/execute.test.js +299 -3
  15. package/dist/acpx-engine/execute.test.js.map +1 -1
  16. package/dist/acpx-engine/session-codec.d.ts.map +1 -1
  17. package/dist/acpx-engine/session-codec.js +1 -0
  18. package/dist/acpx-engine/session-codec.js.map +1 -1
  19. package/dist/acpx-engine/settlement-characterization.test.js +105 -0
  20. package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
  21. package/dist/acpx-engine/spawn-smoke.test.js +16 -7
  22. package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
  23. package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
  24. package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
  25. package/dist/acpx-engine/startup-cancellation.js +7 -2
  26. package/dist/acpx-engine/startup-cancellation.js.map +1 -1
  27. package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
  28. package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
  29. package/dist/acpx-engine/terminal-session-failure.js +106 -0
  30. package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
  31. package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
  32. package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
  33. package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
  34. package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
  35. package/dist/acpx-engine/turn-characterization.test.js +4 -3
  36. package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
  37. package/dist/command-managed-runtime.d.ts +2 -0
  38. package/dist/command-managed-runtime.d.ts.map +1 -1
  39. package/dist/command-managed-runtime.js +2 -0
  40. package/dist/command-managed-runtime.js.map +1 -1
  41. package/dist/command-redaction.d.ts +2 -0
  42. package/dist/command-redaction.d.ts.map +1 -1
  43. package/dist/command-redaction.js +20 -10
  44. package/dist/command-redaction.js.map +1 -1
  45. package/dist/command-redaction.test.js +20 -1
  46. package/dist/command-redaction.test.js.map +1 -1
  47. package/dist/directory-merge-lock.test.d.ts +2 -0
  48. package/dist/directory-merge-lock.test.d.ts.map +1 -0
  49. package/dist/directory-merge-lock.test.js +166 -0
  50. package/dist/directory-merge-lock.test.js.map +1 -0
  51. package/dist/execution-phase.d.ts +10 -0
  52. package/dist/execution-phase.d.ts.map +1 -0
  53. package/dist/execution-phase.js +27 -0
  54. package/dist/execution-phase.js.map +1 -0
  55. package/dist/execution-phase.test.d.ts +2 -0
  56. package/dist/execution-phase.test.d.ts.map +1 -0
  57. package/dist/execution-phase.test.js +26 -0
  58. package/dist/execution-phase.test.js.map +1 -0
  59. package/dist/execution-target-sandbox.test.js +49 -7
  60. package/dist/execution-target-sandbox.test.js.map +1 -1
  61. package/dist/execution-target-stdin-race.test.js +245 -2
  62. package/dist/execution-target-stdin-race.test.js.map +1 -1
  63. package/dist/execution-target.d.ts +6 -0
  64. package/dist/execution-target.d.ts.map +1 -1
  65. package/dist/execution-target.js +74 -22
  66. package/dist/execution-target.js.map +1 -1
  67. package/dist/git-workspace-sync.d.ts +15 -5
  68. package/dist/git-workspace-sync.d.ts.map +1 -1
  69. package/dist/git-workspace-sync.js +267 -124
  70. package/dist/git-workspace-sync.js.map +1 -1
  71. package/dist/git-workspace-sync.test.js +296 -20
  72. package/dist/git-workspace-sync.test.js.map +1 -1
  73. package/dist/github-launcher.d.ts.map +1 -1
  74. package/dist/github-launcher.js +26 -11
  75. package/dist/github-launcher.js.map +1 -1
  76. package/dist/github-launcher.test.js +34 -1
  77. package/dist/github-launcher.test.js.map +1 -1
  78. package/dist/index.d.ts +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/paperclip-runner-permissions.d.ts +28 -1
  83. package/dist/paperclip-runner-permissions.d.ts.map +1 -1
  84. package/dist/paperclip-runner-permissions.js +9 -1
  85. package/dist/paperclip-runner-permissions.js.map +1 -1
  86. package/dist/prompt-sections.test.d.ts +2 -0
  87. package/dist/prompt-sections.test.d.ts.map +1 -0
  88. package/dist/prompt-sections.test.js +53 -0
  89. package/dist/prompt-sections.test.js.map +1 -0
  90. package/dist/provider-stopped-boundary.d.ts +7 -0
  91. package/dist/provider-stopped-boundary.d.ts.map +1 -0
  92. package/dist/provider-stopped-boundary.js +22 -0
  93. package/dist/provider-stopped-boundary.js.map +1 -0
  94. package/dist/provider-stopped-boundary.test.d.ts +2 -0
  95. package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
  96. package/dist/provider-stopped-boundary.test.js +76 -0
  97. package/dist/provider-stopped-boundary.test.js.map +1 -0
  98. package/dist/remote-managed-runtime.d.ts +2 -0
  99. package/dist/remote-managed-runtime.d.ts.map +1 -1
  100. package/dist/remote-managed-runtime.js +3 -1
  101. package/dist/remote-managed-runtime.js.map +1 -1
  102. package/dist/remote-managed-runtime.test.js +25 -0
  103. package/dist/remote-managed-runtime.test.js.map +1 -1
  104. package/dist/sandbox-callback-bridge.d.ts +2 -0
  105. package/dist/sandbox-callback-bridge.d.ts.map +1 -1
  106. package/dist/sandbox-callback-bridge.js +34 -10
  107. package/dist/sandbox-callback-bridge.js.map +1 -1
  108. package/dist/sandbox-callback-bridge.test.js +106 -3
  109. package/dist/sandbox-callback-bridge.test.js.map +1 -1
  110. package/dist/sandbox-managed-runtime.d.ts +3 -0
  111. package/dist/sandbox-managed-runtime.d.ts.map +1 -1
  112. package/dist/sandbox-managed-runtime.js +749 -718
  113. package/dist/sandbox-managed-runtime.js.map +1 -1
  114. package/dist/sandbox-managed-runtime.test.js +171 -2
  115. package/dist/sandbox-managed-runtime.test.js.map +1 -1
  116. package/dist/server-utils.d.ts +43 -1
  117. package/dist/server-utils.d.ts.map +1 -1
  118. package/dist/server-utils.js +103 -31
  119. package/dist/server-utils.js.map +1 -1
  120. package/dist/server-utils.test.js +235 -45
  121. package/dist/server-utils.test.js.map +1 -1
  122. package/dist/session-compaction.d.ts.map +1 -1
  123. package/dist/session-compaction.js +6 -0
  124. package/dist/session-compaction.js.map +1 -1
  125. package/dist/ssh-fixture.test.js +22 -0
  126. package/dist/ssh-fixture.test.js.map +1 -1
  127. package/dist/ssh.d.ts +2 -0
  128. package/dist/ssh.d.ts.map +1 -1
  129. package/dist/ssh.js +2 -2
  130. package/dist/ssh.js.map +1 -1
  131. package/dist/test-fixtures/prompt-context.d.ts +54 -0
  132. package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
  133. package/dist/test-fixtures/prompt-context.js +55 -0
  134. package/dist/test-fixtures/prompt-context.js.map +1 -0
  135. package/dist/types.d.ts +12 -2
  136. package/dist/types.d.ts.map +1 -1
  137. package/dist/workspace-git-stream.d.ts +19 -0
  138. package/dist/workspace-git-stream.d.ts.map +1 -0
  139. package/dist/workspace-git-stream.js +99 -0
  140. package/dist/workspace-git-stream.js.map +1 -0
  141. package/dist/workspace-git-stream.test.d.ts +2 -0
  142. package/dist/workspace-git-stream.test.d.ts.map +1 -0
  143. package/dist/workspace-git-stream.test.js +71 -0
  144. package/dist/workspace-git-stream.test.js.map +1 -0
  145. package/dist/workspace-manifest.d.ts +61 -0
  146. package/dist/workspace-manifest.d.ts.map +1 -0
  147. package/dist/workspace-manifest.js +257 -0
  148. package/dist/workspace-manifest.js.map +1 -0
  149. package/dist/workspace-manifest.test.d.ts +2 -0
  150. package/dist/workspace-manifest.test.d.ts.map +1 -0
  151. package/dist/workspace-manifest.test.js +160 -0
  152. package/dist/workspace-manifest.test.js.map +1 -0
  153. package/dist/workspace-restore-diagnostics.d.ts +34 -0
  154. package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
  155. package/dist/workspace-restore-diagnostics.js +238 -0
  156. package/dist/workspace-restore-diagnostics.js.map +1 -0
  157. package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
  158. package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
  159. package/dist/workspace-restore-diagnostics.test.js +258 -0
  160. package/dist/workspace-restore-diagnostics.test.js.map +1 -0
  161. package/dist/workspace-restore-merge.d.ts +47 -6
  162. package/dist/workspace-restore-merge.d.ts.map +1 -1
  163. package/dist/workspace-restore-merge.js +382 -114
  164. package/dist/workspace-restore-merge.js.map +1 -1
  165. package/dist/workspace-restore-merge.test.js +253 -4
  166. package/dist/workspace-restore-merge.test.js.map +1 -1
  167. package/dist/workspace-restore-result.d.ts +6 -0
  168. package/dist/workspace-restore-result.d.ts.map +1 -0
  169. package/dist/workspace-restore-result.js +72 -0
  170. package/dist/workspace-restore-result.js.map +1 -0
  171. package/dist/workspace-restore-result.test.d.ts +2 -0
  172. package/dist/workspace-restore-result.test.d.ts.map +1 -0
  173. package/dist/workspace-restore-result.test.js +74 -0
  174. package/dist/workspace-restore-result.test.js.map +1 -0
  175. package/dist/workspace-restore-teardown.d.ts.map +1 -1
  176. package/dist/workspace-restore-teardown.js +5 -3
  177. package/dist/workspace-restore-teardown.js.map +1 -1
  178. package/dist/workspace-restore-teardown.test.js +24 -0
  179. package/dist/workspace-restore-teardown.test.js.map +1 -1
  180. package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
  181. package/package.json +2 -2
@@ -2,13 +2,15 @@ import { execFile as execFileCallback } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { constants as fsConstants, createReadStream, promises as fs, } from "node:fs";
4
4
  import os from "node:os";
5
+ import { workspacePaths, workspacePathMatcher, writeWorkspacePaths, isPathManifest } from "./workspace-manifest.js";
5
6
  import path from "node:path";
6
7
  import { promisify } from "node:util";
7
- import { buildRemoteGitDeltaBundleScript, isMissingGitPrerequisiteError, createImportedGitRef, createRemoteGitExportRef, deleteLocalGitRef, fetchGitBundleIntoLocalRef, GIT_ARCHIVE_EXCLUDES, integrateImportedGitHead, readGitWorkspaceSnapshot, ReferencedSourceIgnoreScanLimitExceededError, readReferencedSourceGitIgnoredPaths, resetLocalGitIndexToHead, withShallowGitWorkspaceClone, WORKSPACE_GIT_SCAN_SATURATED_CODE, } from "./git-workspace-sync.js";
8
- import { captureDirectorySnapshot, mergeDirectoryWithBaseline, } from "./workspace-restore-merge.js";
8
+ import { buildRemoteGitDeltaBundleScript, isMissingGitPrerequisiteError, createImportedGitRef, createRemoteGitExportRef, deleteLocalGitRef, fetchGitBundleIntoLocalRef, GIT_ARCHIVE_EXCLUDES, integrateImportedGitHead, readGitWorkspaceSnapshot, disposeGitWorkspaceSnapshot, ReferencedSourceIgnoreScanLimitExceededError, readReferencedSourceGitIgnoredPaths, resetLocalGitIndexToHead, withShallowGitWorkspaceClone, WORKSPACE_GIT_SCAN_SATURATED_CODE, } from "./git-workspace-sync.js";
9
+ import { captureDirectorySnapshot, disposeDirectorySnapshot, selectDirectorySnapshot, mergeDirectoryWithBaseline, } from "./workspace-restore-merge.js";
9
10
  import { createRuntimeProgressReporter, } from "./runtime-progress.js";
10
11
  import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js";
11
12
  import { scheduleSyncOperations, SYNC_OPERATION_CONCURRENCY_LIMIT, } from "./sync-operation-schedule.js";
13
+ import { recordWorkspaceRestoreDiagnostic, withWorkspaceRestoreDiagnostics, withWorkspaceRestoreStep, } from "./workspace-restore-diagnostics.js";
12
14
  const execFile = promisify(execFileCallback);
13
15
  const SANDBOX_WORKSPACE_HEAVY_DIR_NAMES = [
14
16
  "node_modules",
@@ -289,11 +291,20 @@ function buildWorkspaceTarExtractCommand(input) {
289
291
  // sandbox workspace. Every path is shell-quoted; the caller supplies only
290
292
  // already-confined relative paths from the git snapshot.
291
293
  function buildRemoveDeletedPathsCommand(input) {
292
- const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" ");
293
- return `cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`;
294
+ // NUL input plus bounded xargs batches preserve whitespace and never create
295
+ // one argument list for the full snapshot. Refuse symlink ancestors.
296
+ const remove = `for entry do
297
+ parent=$entry
298
+ while [ "\${parent#*/}" != "$parent" ]; do
299
+ parent=\${parent%/*}
300
+ if [ -L "$parent" ] || { [ -e "$parent" ] && [ ! -d "$parent" ]; }; then exit 42; fi
301
+ done
302
+ rm -rf -- "$entry" || exit
303
+ done`;
304
+ return `cd ${shellQuote(input.remoteDir)} && xargs -0 -r -n 64 sh -c ${shellQuote(remove)} sh < ${shellQuote(input.manifestPath)} && rm -f -- ${shellQuote(input.manifestPath)}`;
294
305
  }
295
306
  function buildUniqueStagingPath(input) {
296
- return `${input.targetPath}${input.suffix}.${randomUUID()}`;
307
+ return path.join(path.dirname(input.targetPath), `${input.suffix}.${randomUUID()}`);
297
308
  }
298
309
  // The workspace stages under `<runtimeRootDir>/workspace-upload.tar` and, for a
299
310
  // git-backed workspace, under `<runtimeRootDir>/git-workspace-upload.tar`. Each
@@ -425,49 +436,34 @@ export async function createTarballFromDirectory(input) {
425
436
  // entries avoids the self-entry entirely and is portable across GNU/BSD/busybox
426
437
  // tar (no GNU-only --no-overwrite-dir needed). --exclude still filters nested
427
438
  // matches and any named entry it matches.
428
- const entries = (await fs.readdir(input.localDir)).sort((left, right) => left.localeCompare(right));
429
- if (entries.length === 0) {
430
- // A workspace can legitimately be empty (blank-workspace agent runs). Write a
431
- // valid empty tar archive (1024-byte all-zero EOF marker) so extraction is a
432
- // clean no-op rather than tar refusing to create an empty archive.
433
- await fs.writeFile(input.archivePath, Buffer.alloc(1024));
434
- return;
435
- }
436
- await execTar([
437
- "-c",
438
- // Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended
439
- // headers for extended attributes (e.g. com.apple.provenance). GNU tar on
440
- // Linux does not recognise these proprietary headers and fails extraction
441
- // with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in
442
- // execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs
443
- // additionally suppresses the inline PAX xattr entries.
444
- "--no-xattrs",
445
- ...(input.followSymlinks ? ["-h"] : []),
446
- "-f",
447
- input.archivePath,
448
- "-C",
449
- input.localDir,
450
- ...excludeArgs,
451
- "--",
452
- ...entries,
453
- ]);
439
+ await withTempDir("paperclip-tar-list-", async (directory) => {
440
+ const list = path.join(directory, "entries.nul");
441
+ const file = await fs.open(list, "wx", 0o600);
442
+ try {
443
+ for await (const entry of await fs.opendir(input.localDir))
444
+ await file.writeFile(`${entry.name}\0`);
445
+ }
446
+ finally {
447
+ await file.close();
448
+ }
449
+ await execTar(["-c", "--no-xattrs", ...(input.followSymlinks ? ["-h"] : []),
450
+ "-f", input.archivePath, "-C", input.localDir, ...excludeArgs, "--null", "-T", list]);
451
+ });
454
452
  }
455
453
  async function extractTarballToDirectory(input) {
456
454
  await fs.mkdir(input.localDir, { recursive: true });
457
455
  await execTar(["-xf", input.archivePath, "-C", input.localDir]);
458
456
  }
459
- async function walkDirectory(root, relative = "") {
460
- const current = path.join(root, relative);
461
- const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
462
- const out = [];
463
- for (const entry of entries) {
457
+ async function* walkDirectory(root, relative = "", childrenFirst = false) {
458
+ for await (const entry of await fs.opendir(path.join(root, relative))) {
464
459
  const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
465
- out.push(nextRelative);
466
- if (entry.isDirectory()) {
467
- out.push(...(await walkDirectory(root, nextRelative)));
468
- }
460
+ if (!childrenFirst)
461
+ yield nextRelative;
462
+ if (entry.isDirectory())
463
+ yield* walkDirectory(root, nextRelative, childrenFirst);
464
+ if (childrenFirst)
465
+ yield nextRelative;
469
466
  }
470
- return out.sort((left, right) => right.length - left.length);
471
467
  }
472
468
  async function copyWorkspaceEntry(sourceRoot, targetRoot, relative) {
473
469
  const sourcePath = path.join(sourceRoot, relative);
@@ -501,17 +497,19 @@ export async function mirrorDirectory(sourceDir, targetDir, options = {}) {
501
497
  await fs.mkdir(targetDir, { recursive: true });
502
498
  const preserveAbsent = new Set(options.preserveAbsent ?? []);
503
499
  const shouldPreserveAbsent = (relative) => [...preserveAbsent].some((candidate) => isRelativePathOrDescendant(relative, candidate));
504
- const sourceEntries = new Set(await walkDirectory(sourceDir));
505
- const targetEntries = await walkDirectory(targetDir);
506
- for (const relative of targetEntries) {
500
+ for await (const relative of walkDirectory(targetDir, "", true)) {
507
501
  if (shouldPreserveAbsent(relative))
508
502
  continue;
509
- if (!sourceEntries.has(relative)) {
503
+ const sourceExists = await fs.lstat(path.join(sourceDir, relative)).then(() => true).catch((error) => {
504
+ if (error.code === "ENOENT" || error.code === "ENOTDIR")
505
+ return false;
506
+ throw error;
507
+ });
508
+ if (!sourceExists) {
510
509
  await fs.rm(path.join(targetDir, relative), { recursive: true, force: true }).catch(() => undefined);
511
510
  }
512
511
  }
513
- const entries = (await walkDirectory(sourceDir)).sort((left, right) => left.localeCompare(right));
514
- for (const relative of entries) {
512
+ for await (const relative of walkDirectory(sourceDir)) {
515
513
  await copyWorkspaceEntry(sourceDir, targetDir, relative);
516
514
  }
517
515
  }
@@ -532,42 +530,48 @@ async function copySelectedWorkspaceEntries(input) {
532
530
  }
533
531
  };
534
532
  await assertSourceRoot();
535
- for (const relative of input.relativePaths) {
536
- if (shouldExcludePath(relative, input.exclude))
537
- continue;
538
- const sourcePath = path.join(sourceDir, relative);
539
- const parentSegments = path.relative(sourceDir, path.dirname(sourcePath)).split(path.sep).filter(Boolean);
540
- const assertParentDirectory = async () => {
541
- // Git selected this path before staging. A replaced ancestor must not
542
- // redirect the copy through a symlink, even to another workspace folder.
543
- // Inspect types instead of comparing realpath spelling: case-insensitive
544
- // filesystems can resolve Git's indexed casing to a renamed directory.
545
- let parentPath = sourceDir;
546
- for (const segment of parentSegments) {
547
- if (segment === "..")
548
- throw new Error(`Workspace overlay directory escapes its root: ${relative}`);
549
- parentPath = path.join(parentPath, segment);
550
- if (!(await fs.lstat(parentPath)).isDirectory()) {
551
- throw new Error(`Workspace overlay ancestor is not a directory: ${relative}`);
533
+ const ignored = workspacePathMatcher(input.ignoredPaths);
534
+ try {
535
+ for (const relative of workspacePaths(input.relativePaths)) {
536
+ if (shouldExcludePath(relative, input.exclude) || ignored.matches(relative))
537
+ continue;
538
+ const sourcePath = path.join(sourceDir, relative);
539
+ const parentSegments = path.relative(sourceDir, path.dirname(sourcePath)).split(path.sep).filter(Boolean);
540
+ const assertParentDirectory = async () => {
541
+ // Git selected this path before staging. A replaced ancestor must not
542
+ // redirect the copy through a symlink, even to another workspace folder.
543
+ // Inspect types instead of comparing realpath spelling: case-insensitive
544
+ // filesystems can resolve Git's indexed casing to a renamed directory.
545
+ let parentPath = sourceDir;
546
+ for (const segment of parentSegments) {
547
+ if (segment === "..")
548
+ throw new Error(`Workspace overlay directory escapes its root: ${relative}`);
549
+ parentPath = path.join(parentPath, segment);
550
+ if (!(await fs.lstat(parentPath)).isDirectory()) {
551
+ throw new Error(`Workspace overlay ancestor is not a directory: ${relative}`);
552
+ }
552
553
  }
554
+ };
555
+ // Include root-level entries, and do not treat a missing root as an
556
+ // ordinary source file that disappeared after the snapshot.
557
+ await assertSourceRoot();
558
+ try {
559
+ await assertParentDirectory();
560
+ await fs.lstat(sourcePath);
553
561
  }
554
- };
555
- // Include root-level entries, and do not treat a missing root as an
556
- // ordinary source file that disappeared after the snapshot.
557
- await assertSourceRoot();
558
- try {
562
+ catch (error) {
563
+ if (error.code === "ENOENT")
564
+ continue;
565
+ throw error;
566
+ }
567
+ await copyWorkspaceEntry(sourceDir, input.targetDir, relative);
568
+ // Do not upload the staged tree if an ancestor changed during the copy.
569
+ await assertSourceRoot();
559
570
  await assertParentDirectory();
560
- await fs.lstat(sourcePath);
561
- }
562
- catch (error) {
563
- if (error.code === "ENOENT")
564
- continue;
565
- throw error;
566
571
  }
567
- await copyWorkspaceEntry(sourceDir, input.targetDir, relative);
568
- // Do not upload the staged tree if an ancestor changed during the copy.
569
- await assertSourceRoot();
570
- await assertParentDirectory();
572
+ }
573
+ finally {
574
+ ignored.close();
571
575
  }
572
576
  }
573
577
  function toBuffer(bytes) {
@@ -590,17 +594,13 @@ function tarExcludeFlags(exclude) {
590
594
  return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" ");
591
595
  }
592
596
  function createRemoteTarballFromDirectoryCommand(input) {
593
- // Match the local archive path: name top-level entries explicitly so tar
594
- // does not include a "." self-entry that it later tries to chmod/utime.
597
+ const list = `${input.archivePath}.entries`;
595
598
  return [
596
599
  `mkdir -p ${shellQuote(path.posix.dirname(input.archivePath))}`,
597
600
  `cd ${shellQuote(input.remoteDir)}`,
598
- "set -- *",
599
- `if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi`,
600
- `for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done`,
601
- `if [ "$#" -eq 0 ]; then ` +
602
- `dd if=/dev/zero of=${shellQuote(input.archivePath)} bs=1024 count=1; ` +
603
- `else tar -cf ${shellQuote(input.archivePath)} ${tarExcludeFlags(input.exclude)} -- "$@"; fi`,
601
+ `find . -mindepth 1 -maxdepth 1 -print0 > ${shellQuote(list)}`,
602
+ `tar -cf ${shellQuote(input.archivePath)} ${tarExcludeFlags(input.exclude)} --null -T ${shellQuote(list)}`,
603
+ `rm -f -- ${shellQuote(list)}`,
604
604
  ].join(" && ");
605
605
  }
606
606
  async function emitRuntimeStatus(sink, phase, message) {
@@ -696,193 +696,254 @@ export async function prepareSandboxManagedRuntime(input) {
696
696
  // The git enumeration (`git status --ignored`, the HEAD diffs, `ls-files`).
697
697
  // It reads git's own bookkeeping to decide what to include/exclude, so it is
698
698
  // usually fast, but on a large working tree the `--ignored` walk is not free.
699
- const gitSnapshot = syncWorkspace
699
+ const gitSnapshot = syncWorkspace && input.workspaceFileMode !== "all"
700
700
  ? input.workspaceGitSnapshot !== undefined
701
701
  ? input.workspaceGitSnapshot
702
702
  : await runStepSpan("snapshot.git", () => readGitWorkspaceSnapshot(input.workspaceLocalDir))
703
703
  : null;
704
- // A selected subfolder has no cloneable Git snapshot, but its parent
705
- // repository's ignore rules still govern which files may leave the host.
706
- // Use the same bounded, path-relative resolver as referenced project trees.
707
- const directoryIgnore = syncWorkspace && !gitSnapshot
708
- ? await resolveReferencedSourceIgnore(input.workspaceLocalDir)
709
- : null;
710
- if (directoryIgnore?.kind === "failed") {
711
- throw new Error(`Workspace ignore scan failed: ${directoryIgnore.reason}`);
712
- }
713
- const gitIgnoredExcludes = gitSnapshot?.ignoredPaths
714
- ?? (directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined);
715
- const workspaceArchiveExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], input.workspaceExclude, gitIgnoredExcludes);
716
- const restoreExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], input.preserveAbsentOnRestore, input.workspaceExclude, gitIgnoredExcludes);
717
- const repositories = gitSnapshot?.repositories ?? [];
718
- const workspaceRestoreExclude = mergeExcludes(restoreExclude, repositories.map((repo) => repo.path));
719
- // The baseline "before" snapshot: a recursive walk of the whole workspace that
720
- // `lstat`s every entry and SHA-256-hashes every file's bytes. This is the
721
- // dominant cost in the pre-`pack` window — it reads the content of every
722
- // non-excluded file, serially — so it earns its own span.
723
- const baselineSnapshot = syncWorkspace
724
- ? (input.workspaceBaseline ??
725
- (await runStepSpan("snapshot.baseline", () => captureDirectorySnapshot(input.workspaceLocalDir, {
726
- exclude: restoreExclude,
727
- }))))
728
- : null;
729
- // Every inbound staging step delegates to the provider through `client.syncIn`:
730
- // the orchestrator no longer inlines `writeFile`+`run` or chooses a transport,
731
- // and there is no `usesCustomProvision` native-diversion gate. `syncIn` is
732
- // ALWAYS present in production — the command-managed client exposes a native
733
- // transport (Daytona/Kubernetes `uploadFiles` + provider-executed post-upload
734
- // commands) or a byte-identical base64-tar fallback that reproduces the prior
735
- // `writeFile`+`run` sequence. Require it explicitly so a misconfigured client
736
- // fails loud rather than silently skipping staging. `syncOut` stays optional
737
- // (native-only) with a tar fallback on the restore path below.
738
- const syncIn = input.client.syncIn;
739
- if (typeof syncIn !== "function") {
740
- throw new Error("prepareSandboxManagedRuntime requires a client that exposes syncIn " +
741
- "(createCommandManagedRuntimeClient provides a native-or-fallback implementation).");
742
- }
743
- const nativeSyncOut = typeof input.client.syncOut === "function";
744
- let syncOperationSeq = 0;
745
- // Opaque, ordered, non-sensitive operation tokens — never a caller/asset id.
746
- const nextSyncOperationId = () => `sync-op-${++syncOperationSeq}`;
747
- // Remote directory of each additional (referenced) project that stages
748
- // successfully, keyed by projectId. A project that fails to stage is absent.
749
- const additionalSourceDirs = {};
750
- // Each additional (referenced) project whose staging failed, paired with the
751
- // failure message. Per-project failure isolation keeps the run and the other
752
- // projects going; this list makes each failure a first-class, reported outcome.
753
- const additionalSourceFailures = [];
754
- // Additional projects stage as plain trees. Drop the heavy build/cache dirs a
755
- // reference tree does not need, and `.git` — additional sources never carry
756
- // git-history semantics (anchor-only). Each project also drops its OWN
757
- // resolved Git-ignored paths (or keeps this fixed set as-is for a non-Git
758
- // source) — see `resolveReferencedSourceIgnore` and the per-project merge
759
- // below.
760
- const additionalSourceBaseExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]);
761
- // Every delegated post-upload command (extract/wipe/remove-deleted/asset merge)
762
- // must run under the run-specific timeout (`spec.timeoutMs`), not the provider
763
- // sync client's default timeout — the two can differ, and before staging was
764
- // routed through `syncIn` each of these ran via
765
- // `client.run(cmd, { timeoutMs: spec.timeoutMs })`. When they mismatch, a
766
- // command left without a `timeoutMs` outlives (or is killed under) the wrong
767
- // limit. Stamp the run timeout onto every delegated command, preserving any
768
- // command that already carries its own explicit timeout.
769
- const withRunTimeout = (commands) => commands.map((command) => ({
770
- ...command,
771
- timeoutMs: command.timeoutMs ?? input.spec.timeoutMs,
772
- }));
773
- await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
774
- const preservedNames = new Set([
775
- ".paperclip-runtime",
776
- ...(gitSnapshot ? [".git"] : []),
777
- ...(input.preserveAbsentOnRestore ?? []),
778
- ]);
779
- // Stage one source directory into an isolated remote subdirectory through the
780
- // unified `syncIn` seam. Both the workspace/asset tar path and the additional
781
- // (referenced) project path use it: build one `SandboxSyncOperation` from the
782
- // caller's `files` mappings (a host tarball as a single `file` mapping, or a
783
- // whole `directory` mapping), carry any extract/wipe/merge steps as ordered
784
- // `postUploadCommands`, confine the operation's source and target to their
785
- // own roots (fail-closed), and delegate to `syncIn` (native transfer, or the
786
- // base64-tar fallback). `finish` emits the terminal progress line.
787
- const stageConfinedSyncIn = async (params) => {
788
- const operations = [{
789
- operationId: nextSyncOperationId(),
790
- files: params.files,
791
- ...(params.postUploadCommands
792
- ? { postUploadCommands: withRunTimeout(params.postUploadCommands) }
793
- : {}),
794
- }];
795
- assertSyncOperationsConfined(operations, {
796
- sourceRoots: params.sourceRoots,
797
- targetRoots: params.targetRoots,
798
- });
799
- const upload = makeTransferProgress(input.onProgress, "Syncing", "to", params.progressLabel, { sink: input.onRuntimeProgress, phase: params.statusPhase });
800
- const syncResult = await syncIn(operations);
801
- // Prefer the transport's own byte total. It is the real count for a
802
- // provider that has no host tarball to stat (a referenced project rides
803
- // a `directory` mapping). Fall back to the caller-supplied count when
804
- // the transport reports 0, so a provider that under-reports still
805
- // shows the host-known workspace total.
806
- const transferredBytes = sumSyncResultBytes(syncResult);
807
- const reportedBytes = transferredBytes > 0 ? transferredBytes : params.progressBytes;
808
- await upload.finish(reportedBytes, reportedBytes);
809
- };
810
- // Build the ordered inbound operation task list. Each task stages one inbound
811
- // operation from start to end: it packs the bytes, confines the mappings, and
812
- // uploads them. The shared scheduler starts the tasks. When the sync client
813
- // permits concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
814
- // tasks active. When the client does not permit concurrency, the scheduler runs
815
- // one task at a time in this order. The scheduler settles every started task
816
- // before it returns, so no upload outlives the coordinator and ACP never starts
817
- // before the barrier.
818
- const inboundTasks = [];
819
- // A required-flag list, one entry per task and index-aligned with inboundTasks.
820
- // The workspace and each asset are required operations: a rejection is fatal
821
- // after the barrier. Each referenced project is a nonfatal operation: its task
822
- // records its own failure and never rejects.
823
- const inboundTaskIsRequired = [];
824
- if (stageWorkspace || prepareWorkspaceSeed) {
825
- inboundTaskIsRequired.push(true);
826
- inboundTasks.push(() => runStepSpan("stage.workspace", async () => {
827
- // A git-backed workspace and a plain workspace both stage through ONE
828
- // confined `syncIn` operation. A git-backed workspace carries TWO host tars —
829
- // the git-history clone and the working-tree overlay — as two `file` mappings
830
- // on the SAME operation, with their extract commands as ordered
831
- // `postUploadCommands`. One operation shares one mkdir, one confine guard, one
832
- // `uploadFiles`, and one rename exec, so the second `syncIn` round trip is
833
- // removed. Build the whole merged file set and command list BEFORE the confine
834
- // guard runs (inside `stageConfinedSyncIn`); never append a mapping after it.
835
- const workspaceFiles = [];
836
- const workspacePostUploadCommands = [];
837
- let workspaceUploadBytes = 0;
838
- // Build both host tarballs (the git-history tar and the workspace-overlay
839
- // tar) inside one host span named `pack`. This span makes the host pack
840
- // time visible under the `stage.sync` step, where it is otherwise a hidden
841
- // gap with no span. The transfer (`stageConfinedSyncIn`) runs after this
842
- // span, so `pack` measures only the host tar-build cost. The runner
843
- // defaults to a no-op, so a caller with no injected runner keeps the
844
- // current behavior and control flow.
845
- await runStepSpan("pack", async () => {
846
- // 1. git-history tar (git-backed workspace only). Both tar targets live under
847
- // `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
848
- // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
849
- // which sits under `.paperclip-runtime`, survives to run its own extract.
850
- if (gitSnapshot) {
851
- await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to environment");
852
- const gitTarPath = path.join(tempDir, "git-workspace.tar");
853
- const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
854
- if (workspaceInboundMode === "durable_seed") {
855
- const durableGitArchive = input.workspaceDurableSeed?.gitArchivePath;
856
- if (!durableGitArchive) {
857
- throw new Error("workspace_durable_seed_git_missing");
704
+ let baselineSnapshot = null;
705
+ const cleanupWorkspaceSnapshot = async () => {
706
+ await disposeDirectorySnapshot(baselineSnapshot);
707
+ await disposeGitWorkspaceSnapshot(gitSnapshot);
708
+ };
709
+ try {
710
+ // A selected subfolder has no cloneable Git snapshot, but its parent
711
+ // repository's ignore rules still govern which files may leave the host.
712
+ // Use the same bounded, path-relative resolver as referenced project trees.
713
+ const directoryIgnore = syncWorkspace && !gitSnapshot && input.workspaceFileMode !== "all"
714
+ ? await resolveReferencedSourceIgnore(input.workspaceLocalDir)
715
+ : null;
716
+ if (directoryIgnore?.kind === "failed") {
717
+ throw new Error(`Workspace ignore scan failed: ${directoryIgnore.reason}`);
718
+ }
719
+ const gitIgnoredExcludes = directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined;
720
+ const workspaceArchiveExclude = mergeExcludes(input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES], input.workspaceExclude, gitIgnoredExcludes);
721
+ const restoreExclude = mergeExcludes(input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], input.preserveAbsentOnRestore, input.workspaceExclude, gitIgnoredExcludes);
722
+ const repositories = gitSnapshot?.repositories ?? [];
723
+ const workspaceRestoreExclude = mergeExcludes(restoreExclude, repositories.map((repo) => repo.path));
724
+ // The baseline "before" snapshot: a recursive walk of the whole workspace that
725
+ // `lstat`s every entry and SHA-256-hashes every file's bytes. This is the
726
+ // dominant cost in the pre-`pack` window — it reads the content of every
727
+ // non-excluded file, serially — so it earns its own span.
728
+ baselineSnapshot = syncWorkspace
729
+ ? (input.workspaceBaseline ??
730
+ (await runStepSpan("snapshot.baseline", () => captureDirectorySnapshot(input.workspaceLocalDir, {
731
+ exclude: restoreExclude,
732
+ ignoredPaths: gitSnapshot?.ignoredPaths,
733
+ diskBacked: true,
734
+ }))))
735
+ : null;
736
+ // Every inbound staging step delegates to the provider through `client.syncIn`:
737
+ // the orchestrator no longer inlines `writeFile`+`run` or chooses a transport,
738
+ // and there is no `usesCustomProvision` native-diversion gate. `syncIn` is
739
+ // ALWAYS present in production — the command-managed client exposes a native
740
+ // transport (Daytona/Kubernetes `uploadFiles` + provider-executed post-upload
741
+ // commands) or a byte-identical base64-tar fallback that reproduces the prior
742
+ // `writeFile`+`run` sequence. Require it explicitly so a misconfigured client
743
+ // fails loud rather than silently skipping staging. `syncOut` stays optional
744
+ // (native-only) with a tar fallback on the restore path below.
745
+ const syncIn = input.client.syncIn;
746
+ if (typeof syncIn !== "function") {
747
+ throw new Error("prepareSandboxManagedRuntime requires a client that exposes syncIn " +
748
+ "(createCommandManagedRuntimeClient provides a native-or-fallback implementation).");
749
+ }
750
+ const nativeSyncOut = typeof input.client.syncOut === "function";
751
+ let syncOperationSeq = 0;
752
+ // Opaque, ordered, non-sensitive operation tokens — never a caller/asset id.
753
+ const nextSyncOperationId = () => `sync-op-${++syncOperationSeq}`;
754
+ // Remote directory of each additional (referenced) project that stages
755
+ // successfully, keyed by projectId. A project that fails to stage is absent.
756
+ const additionalSourceDirs = {};
757
+ // Each additional (referenced) project whose staging failed, paired with the
758
+ // failure message. Per-project failure isolation keeps the run and the other
759
+ // projects going; this list makes each failure a first-class, reported outcome.
760
+ const additionalSourceFailures = [];
761
+ // Additional projects stage as plain trees. Drop the heavy build/cache dirs a
762
+ // reference tree does not need, and `.git` — additional sources never carry
763
+ // git-history semantics (anchor-only). Each project also drops its OWN
764
+ // resolved Git-ignored paths (or keeps this fixed set as-is for a non-Git
765
+ // source) — see `resolveReferencedSourceIgnore` and the per-project merge
766
+ // below.
767
+ const additionalSourceBaseExclude = mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [".git"]);
768
+ // Every delegated post-upload command (extract/wipe/remove-deleted/asset merge)
769
+ // must run under the run-specific timeout (`spec.timeoutMs`), not the provider
770
+ // sync client's default timeout — the two can differ, and before staging was
771
+ // routed through `syncIn` each of these ran via
772
+ // `client.run(cmd, { timeoutMs: spec.timeoutMs })`. When they mismatch, a
773
+ // command left without a `timeoutMs` outlives (or is killed under) the wrong
774
+ // limit. Stamp the run timeout onto every delegated command, preserving any
775
+ // command that already carries its own explicit timeout.
776
+ const withRunTimeout = (commands) => commands.map((command) => ({
777
+ ...command,
778
+ timeoutMs: command.timeoutMs ?? input.spec.timeoutMs,
779
+ }));
780
+ await withTempDir("paperclip-sandbox-sync-", async (tempDir) => {
781
+ const preservedNames = new Set([
782
+ ".paperclip-runtime",
783
+ ...(gitSnapshot ? [".git"] : []),
784
+ ...(input.preserveAbsentOnRestore ?? []),
785
+ ]);
786
+ // Stage one source directory into an isolated remote subdirectory through the
787
+ // unified `syncIn` seam. Both the workspace/asset tar path and the additional
788
+ // (referenced) project path use it: build one `SandboxSyncOperation` from the
789
+ // caller's `files` mappings (a host tarball as a single `file` mapping, or a
790
+ // whole `directory` mapping), carry any extract/wipe/merge steps as ordered
791
+ // `postUploadCommands`, confine the operation's source and target to their
792
+ // own roots (fail-closed), and delegate to `syncIn` (native transfer, or the
793
+ // base64-tar fallback). `finish` emits the terminal progress line.
794
+ const stageConfinedSyncIn = async (params) => {
795
+ const operations = [{
796
+ operationId: nextSyncOperationId(),
797
+ files: params.files,
798
+ ...(params.postUploadCommands
799
+ ? { postUploadCommands: withRunTimeout(params.postUploadCommands) }
800
+ : {}),
801
+ }];
802
+ assertSyncOperationsConfined(operations, {
803
+ sourceRoots: params.sourceRoots,
804
+ targetRoots: params.targetRoots,
805
+ });
806
+ const upload = makeTransferProgress(input.onProgress, "Syncing", "to", params.progressLabel, { sink: input.onRuntimeProgress, phase: params.statusPhase });
807
+ const syncResult = await syncIn(operations);
808
+ // Prefer the transport's own byte total. It is the real count for a
809
+ // provider that has no host tarball to stat (a referenced project rides
810
+ // a `directory` mapping). Fall back to the caller-supplied count when
811
+ // the transport reports 0, so a provider that under-reports still
812
+ // shows the host-known workspace total.
813
+ const transferredBytes = sumSyncResultBytes(syncResult);
814
+ const reportedBytes = transferredBytes > 0 ? transferredBytes : params.progressBytes;
815
+ await upload.finish(reportedBytes, reportedBytes);
816
+ };
817
+ // Build the ordered inbound operation task list. Each task stages one inbound
818
+ // operation from start to end: it packs the bytes, confines the mappings, and
819
+ // uploads them. The shared scheduler starts the tasks. When the sync client
820
+ // permits concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
821
+ // tasks active. When the client does not permit concurrency, the scheduler runs
822
+ // one task at a time in this order. The scheduler settles every started task
823
+ // before it returns, so no upload outlives the coordinator and ACP never starts
824
+ // before the barrier.
825
+ const inboundTasks = [];
826
+ // A required-flag list, one entry per task and index-aligned with inboundTasks.
827
+ // The workspace and each asset are required operations: a rejection is fatal
828
+ // after the barrier. Each referenced project is a nonfatal operation: its task
829
+ // records its own failure and never rejects.
830
+ const inboundTaskIsRequired = [];
831
+ if (stageWorkspace || prepareWorkspaceSeed) {
832
+ inboundTaskIsRequired.push(true);
833
+ inboundTasks.push(() => runStepSpan("stage.workspace", async () => {
834
+ // A git-backed workspace and a plain workspace both stage through ONE
835
+ // confined `syncIn` operation. A git-backed workspace carries TWO host tars —
836
+ // the git-history clone and the working-tree overlay — as two `file` mappings
837
+ // on the SAME operation, with their extract commands as ordered
838
+ // `postUploadCommands`. One operation shares one mkdir, one confine guard, one
839
+ // `uploadFiles`, and one rename exec, so the second `syncIn` round trip is
840
+ // removed. Build the whole merged file set and command list BEFORE the confine
841
+ // guard runs (inside `stageConfinedSyncIn`); never append a mapping after it.
842
+ const workspaceFiles = [];
843
+ const workspacePostUploadCommands = [];
844
+ let workspaceUploadBytes = 0;
845
+ // Build both host tarballs (the git-history tar and the workspace-overlay
846
+ // tar) inside one host span named `pack`. This span makes the host pack
847
+ // time visible under the `stage.sync` step, where it is otherwise a hidden
848
+ // gap with no span. The transfer (`stageConfinedSyncIn`) runs after this
849
+ // span, so `pack` measures only the host tar-build cost. The runner
850
+ // defaults to a no-op, so a caller with no injected runner keeps the
851
+ // current behavior and control flow.
852
+ await runStepSpan("pack", async () => {
853
+ // 1. git-history tar (git-backed workspace only). Both tar targets live under
854
+ // `runtimeRootDir` (`.paperclip-runtime/<adapterKey>`). The git extract
855
+ // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
856
+ // which sits under `.paperclip-runtime`, survives to run its own extract.
857
+ if (gitSnapshot) {
858
+ await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to environment");
859
+ const gitTarPath = path.join(tempDir, "git-workspace.tar");
860
+ const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
861
+ if (workspaceInboundMode === "durable_seed") {
862
+ const durableGitArchive = input.workspaceDurableSeed?.gitArchivePath;
863
+ if (!durableGitArchive) {
864
+ throw new Error("workspace_durable_seed_git_missing");
865
+ }
866
+ await copyDurableSeedArchive({
867
+ sourcePath: durableGitArchive,
868
+ targetPath: gitTarPath,
869
+ expectedSha256: input.workspaceDurableSeed?.gitArchiveSha256,
870
+ });
871
+ }
872
+ else {
873
+ await withShallowGitWorkspaceClone({
874
+ localDir: input.workspaceLocalDir,
875
+ snapshot: gitSnapshot,
876
+ }, async (cloneDir) => {
877
+ await createTarballFromDirectory({
878
+ localDir: cloneDir,
879
+ archivePath: gitTarPath,
880
+ exclude: [".paperclip-runtime"],
881
+ });
882
+ });
883
+ if (input.workspaceDurableSeed?.gitArchivePath) {
884
+ await persistDurableSeedArchive({
885
+ sourcePath: gitTarPath,
886
+ targetPath: input.workspaceDurableSeed.gitArchivePath,
887
+ });
888
+ }
858
889
  }
890
+ workspaceFiles.push({
891
+ sourcePath: gitTarPath,
892
+ targetPath: remoteGitTar,
893
+ kind: "file",
894
+ access: "rw",
895
+ writablePath: workspaceRemoteDir,
896
+ });
897
+ workspacePostUploadCommands.push({
898
+ command: buildWorkspaceTarExtractCommand({
899
+ workspaceRemoteDir,
900
+ remoteTar: remoteGitTar,
901
+ wipeExceptNames: [".paperclip-runtime"],
902
+ }),
903
+ });
904
+ workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
905
+ }
906
+ // 2. workspace-overlay tar. A git-backed overlay merges on top of the just
907
+ // extracted git tree (no wipe); a plain workspace wipes every child except
908
+ // the preserved names first. The extract runs AFTER the git extract.
909
+ await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to environment");
910
+ const workspaceTarPath = path.join(tempDir, "workspace.tar");
911
+ if (workspaceInboundMode === "durable_seed") {
859
912
  await copyDurableSeedArchive({
860
- sourcePath: durableGitArchive,
861
- targetPath: gitTarPath,
862
- expectedSha256: input.workspaceDurableSeed?.gitArchiveSha256,
913
+ sourcePath: input.workspaceDurableSeed.workspaceArchivePath,
914
+ targetPath: workspaceTarPath,
915
+ expectedSha256: input.workspaceDurableSeed.workspaceArchiveSha256,
863
916
  });
864
917
  }
865
918
  else {
866
- await withShallowGitWorkspaceClone({
867
- localDir: input.workspaceLocalDir,
868
- snapshot: gitSnapshot,
869
- }, async (cloneDir) => {
870
- await createTarballFromDirectory({
871
- localDir: cloneDir,
872
- archivePath: gitTarPath,
873
- exclude: [".paperclip-runtime"],
919
+ const workspaceArchiveDir = gitSnapshot
920
+ ? path.join(tempDir, "workspace-overlay")
921
+ : input.workspaceLocalDir;
922
+ if (gitSnapshot) {
923
+ await copySelectedWorkspaceEntries({
924
+ sourceRoot: workspaceRoot,
925
+ targetDir: workspaceArchiveDir,
926
+ relativePaths: gitSnapshot.overlayPaths,
927
+ ignoredPaths: gitSnapshot.ignoredPaths,
928
+ exclude: workspaceArchiveExclude,
874
929
  });
930
+ }
931
+ await createTarballFromDirectory({
932
+ localDir: workspaceArchiveDir,
933
+ archivePath: workspaceTarPath,
934
+ exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
875
935
  });
876
- if (input.workspaceDurableSeed?.gitArchivePath) {
936
+ if (input.workspaceDurableSeed) {
877
937
  await persistDurableSeedArchive({
878
- sourcePath: gitTarPath,
879
- targetPath: input.workspaceDurableSeed.gitArchivePath,
938
+ sourcePath: workspaceTarPath,
939
+ targetPath: input.workspaceDurableSeed.workspaceArchivePath,
880
940
  });
881
941
  }
882
942
  }
943
+ const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
883
944
  workspaceFiles.push({
884
- sourcePath: gitTarPath,
885
- targetPath: remoteGitTar,
945
+ sourcePath: workspaceTarPath,
946
+ targetPath: remoteWorkspaceTar,
886
947
  kind: "file",
887
948
  access: "rw",
888
949
  writablePath: workspaceRemoteDir,
@@ -890,498 +951,468 @@ export async function prepareSandboxManagedRuntime(input) {
890
951
  workspacePostUploadCommands.push({
891
952
  command: buildWorkspaceTarExtractCommand({
892
953
  workspaceRemoteDir,
893
- remoteTar: remoteGitTar,
894
- wipeExceptNames: [".paperclip-runtime"],
954
+ remoteTar: remoteWorkspaceTar,
955
+ wipeExceptNames: gitSnapshot ? null : [...preservedNames],
895
956
  }),
896
957
  });
897
- workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
898
- }
899
- // 2. workspace-overlay tar. A git-backed overlay merges on top of the just
900
- // extracted git tree (no wipe); a plain workspace wipes every child except
901
- // the preserved names first. The extract runs AFTER the git extract.
902
- await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to environment");
903
- const workspaceTarPath = path.join(tempDir, "workspace.tar");
904
- if (workspaceInboundMode === "durable_seed") {
905
- await copyDurableSeedArchive({
906
- sourcePath: input.workspaceDurableSeed.workspaceArchivePath,
907
- targetPath: workspaceTarPath,
908
- expectedSha256: input.workspaceDurableSeed.workspaceArchiveSha256,
909
- });
910
- }
911
- else {
912
- const workspaceArchiveDir = gitSnapshot
913
- ? path.join(tempDir, "workspace-overlay")
914
- : input.workspaceLocalDir;
915
- if (gitSnapshot) {
916
- await copySelectedWorkspaceEntries({
917
- sourceRoot: workspaceRoot,
918
- targetDir: workspaceArchiveDir,
919
- relativePaths: gitSnapshot.overlayPaths,
920
- exclude: workspaceArchiveExclude,
921
- });
922
- }
923
- await createTarballFromDirectory({
924
- localDir: workspaceArchiveDir,
925
- archivePath: workspaceTarPath,
926
- exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
927
- });
928
- if (input.workspaceDurableSeed) {
929
- await persistDurableSeedArchive({
930
- sourcePath: workspaceTarPath,
931
- targetPath: input.workspaceDurableSeed.workspaceArchivePath,
932
- });
958
+ // 3. Optional remove-deleted-paths command runs LAST, after both extracts.
959
+ if (gitSnapshot && (isPathManifest(gitSnapshot.deletedPaths) ? gitSnapshot.deletedPaths.count : gitSnapshot.deletedPaths.length) > 0) {
960
+ const deletedManifest = path.join(tempDir, "deleted.nul");
961
+ const remoteDeletedManifest = path.posix.join(runtimeRootDir, "deleted.nul");
962
+ await writeWorkspacePaths(gitSnapshot.deletedPaths, deletedManifest);
963
+ workspaceFiles.push({ sourcePath: deletedManifest, targetPath: remoteDeletedManifest, kind: "file", access: "rw", writablePath: runtimeRootDir });
964
+ workspacePostUploadCommands.push({ command: buildRemoveDeletedPathsCommand({ remoteDir: workspaceRemoteDir, manifestPath: remoteDeletedManifest }) });
933
965
  }
934
- }
935
- const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
936
- workspaceFiles.push({
937
- sourcePath: workspaceTarPath,
938
- targetPath: remoteWorkspaceTar,
939
- kind: "file",
940
- access: "rw",
941
- writablePath: workspaceRemoteDir,
966
+ workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
942
967
  });
943
- workspacePostUploadCommands.push({
944
- command: buildWorkspaceTarExtractCommand({
945
- workspaceRemoteDir,
946
- remoteTar: remoteWorkspaceTar,
947
- wipeExceptNames: gitSnapshot ? null : [...preservedNames],
948
- }),
968
+ if (!stageWorkspace)
969
+ return;
970
+ // One confined `syncIn` for the whole merged workspace file set. The confine
971
+ // guard covers every mapping BEFORE any bytes upload (fail-closed): a source
972
+ // or target escape in EITHER tar mapping stops the upload of both.
973
+ await stageConfinedSyncIn({
974
+ files: workspaceFiles,
975
+ postUploadCommands: workspacePostUploadCommands,
976
+ sourceRoots: [tempDir],
977
+ targetRoots: [runtimeRootDir],
978
+ progressLabel: "workspace",
979
+ statusPhase: "config_sync",
980
+ progressBytes: workspaceUploadBytes,
949
981
  });
950
- // 3. Optional remove-deleted-paths command runs LAST, after both extracts.
951
- if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) {
952
- workspacePostUploadCommands.push({
953
- command: buildRemoveDeletedPathsCommand({
954
- remoteDir: workspaceRemoteDir,
955
- deletedPaths: gitSnapshot.deletedPaths,
956
- }),
957
- });
958
- }
959
- workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
960
- });
961
- if (!stageWorkspace)
962
- return;
963
- // One confined `syncIn` for the whole merged workspace file set. The confine
964
- // guard covers every mapping BEFORE any bytes upload (fail-closed): a source
965
- // or target escape in EITHER tar mapping stops the upload of both.
966
- await stageConfinedSyncIn({
967
- files: workspaceFiles,
968
- postUploadCommands: workspacePostUploadCommands,
969
- sourceRoots: [tempDir],
970
- targetRoots: [runtimeRootDir],
971
- progressLabel: "workspace",
972
- statusPhase: "config_sync",
973
- progressBytes: workspaceUploadBytes,
974
- });
975
- }));
976
- }
977
- for (const asset of input.assets ?? []) {
978
- inboundTaskIsRequired.push(true);
979
- inboundTasks.push(() => runStepSpan(`stage.asset.${asset.key}`, async () => {
980
- await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to environment");
981
- const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
982
- const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
983
- // Every asset — default OR custom-provisioned (e.g. an adapter credential
984
- // merge) — rides one `syncIn` operation: the asset tar plus any staged
985
- // helper files as `files` mappings, and the extract/merge command as the
986
- // ordered post-upload command. There is no native-diversion gate; a
987
- // custom-provisioned asset's bytes now ride native `uploadFiles` and its
988
- // command runs as a provider-executed post-upload command.
989
- const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
990
- await createTarballFromDirectory({
991
- localDir: asset.localDir,
992
- archivePath: assetTarPath,
993
- followSymlinks: asset.followSymlinks,
994
- exclude: asset.exclude,
995
- });
996
- const files = [
997
- { sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir },
998
- ];
999
- // Stage provision helper files (e.g. the merge scripts) into the temp dir
1000
- // and map them alongside the asset tar so they ride the same native upload.
1001
- for (const stageFile of asset.provision?.stageFiles ?? []) {
1002
- const safeName = stageFile.name;
1003
- if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") {
1004
- throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`);
1005
- }
1006
- const stageBytes = typeof stageFile.contents === "string"
1007
- ? Buffer.from(stageFile.contents)
1008
- : stageFile.contents;
1009
- const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`);
1010
- await fs.writeFile(stageHostPath, stageBytes);
1011
- // A stage helper file (for example a merge script) is a read-only input
1012
- // that the provision command reads; the agent does not change it and does
1013
- // not keep it. So it is `access: "ro"` and never joins the writable set.
1014
- files.push({
1015
- sourcePath: stageHostPath,
1016
- targetPath: path.posix.join(runtimeRootDir, safeName),
1017
- kind: "file",
1018
- access: "ro",
982
+ }));
983
+ }
984
+ for (const asset of input.assets ?? []) {
985
+ inboundTaskIsRequired.push(true);
986
+ inboundTasks.push(() => runStepSpan(`stage.asset.${asset.key}`, async () => {
987
+ await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to environment");
988
+ const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key);
989
+ const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`);
990
+ // Every asset — default OR custom-provisioned (e.g. an adapter credential
991
+ // merge) — rides one `syncIn` operation: the asset tar plus any staged
992
+ // helper files as `files` mappings, and the extract/merge command as the
993
+ // ordered post-upload command. There is no native-diversion gate; a
994
+ // custom-provisioned asset's bytes now ride native `uploadFiles` and its
995
+ // command runs as a provider-executed post-upload command.
996
+ const assetTarPath = path.join(tempDir, `${asset.key}.tar`);
997
+ await createTarballFromDirectory({
998
+ localDir: asset.localDir,
999
+ archivePath: assetTarPath,
1000
+ followSymlinks: asset.followSymlinks,
1001
+ exclude: asset.exclude,
1019
1002
  });
1020
- }
1021
- const postUploadCommand = asset.provision?.postUploadCommand?.({
1022
- assetTarPath: remoteAssetTar,
1023
- assetDir: remoteAssetDir,
1024
- runtimeRootDir,
1025
- }) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar });
1026
- const assetTarSize = (await fs.stat(assetTarPath)).size;
1027
- await stageConfinedSyncIn({
1028
- files,
1029
- postUploadCommands: [{ command: postUploadCommand }],
1030
- sourceRoots: [tempDir],
1031
- targetRoots: [runtimeRootDir],
1032
- progressLabel: asset.key,
1033
- statusPhase: "config_sync",
1034
- progressBytes: assetTarSize,
1035
- });
1036
- }));
1037
- }
1038
- // Stage each referenced (additional) project as a plain, read-only tree in
1039
- // its OWN isolated remote directory (`project-<projectId>`). An additional
1040
- // project rides one confined `syncIn` directory mapping — a native directory
1041
- // transfer, or the base64-tar fallback — with source and target confined to
1042
- // their own roots. No workspace, git-history, or `.paperclip-runtime`
1043
- // semantics apply; those stay anchor-only. Per-project failure isolation: one
1044
- // project's confinement or sync failure logs a warning and is skipped, and
1045
- // the run plus the other projects continue. Only a project that stages
1046
- // successfully appears in `additionalSourceDirs`.
1047
- const additionalSourceList = input.additionalSources ?? [];
1048
- // Record each referenced-project failure in its input-order slot. Slot order
1049
- // keeps the failure list stable no matter the task completion order under
1050
- // concurrency.
1051
- const additionalFailureSlots = additionalSourceList.map(() => null);
1052
- additionalSourceList.forEach((source, sourceIndex) => {
1053
- inboundTaskIsRequired.push(false);
1054
- inboundTasks.push(() => runStepSpan(`stage.project.${source.projectId}`, async () => {
1055
- const { localPath, projectId, ignoreResolution } = source;
1056
- const label = `project-${projectId}`;
1057
- try {
1058
- if (!path.posix.isAbsolute(localPath)) {
1059
- throw new Error(`additional source localPath is not an absolute path: ${localPath}`);
1060
- }
1061
- if (projectId.length === 0 ||
1062
- projectId.includes("/") ||
1063
- projectId.includes("\\") ||
1064
- projectId.includes("..")) {
1065
- throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
1066
- }
1067
- // Fail closed: a project whose ignore resolution failed is not staged
1068
- // at all. Shipping it with only the fixed heavy-directory excludes
1069
- // would defeat the resolution's purpose.
1070
- if (ignoreResolution.kind === "failed") {
1071
- throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`);
1003
+ const files = [
1004
+ { sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir },
1005
+ ];
1006
+ // Stage provision helper files (e.g. the merge scripts) into the temp dir
1007
+ // and map them alongside the asset tar so they ride the same native upload.
1008
+ for (const stageFile of asset.provision?.stageFiles ?? []) {
1009
+ const safeName = stageFile.name;
1010
+ if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") {
1011
+ throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`);
1012
+ }
1013
+ const stageBytes = typeof stageFile.contents === "string"
1014
+ ? Buffer.from(stageFile.contents)
1015
+ : stageFile.contents;
1016
+ const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`);
1017
+ await fs.writeFile(stageHostPath, stageBytes);
1018
+ // A stage helper file (for example a merge script) is a read-only input
1019
+ // that the provision command reads; the agent does not change it and does
1020
+ // not keep it. So it is `access: "ro"` and never joins the writable set.
1021
+ files.push({
1022
+ sourcePath: stageHostPath,
1023
+ targetPath: path.posix.join(runtimeRootDir, safeName),
1024
+ kind: "file",
1025
+ access: "ro",
1026
+ });
1072
1027
  }
1073
- const remoteProjectDir = path.posix.join(runtimeRootDir, label);
1074
- const exclude = mergeExcludes(additionalSourceBaseExclude, referencedSourceIgnoreExcludeEntries(ignoreResolution));
1075
- await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to environment");
1028
+ const postUploadCommand = asset.provision?.postUploadCommand?.({
1029
+ assetTarPath: remoteAssetTar,
1030
+ assetDir: remoteAssetDir,
1031
+ runtimeRootDir,
1032
+ }) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar });
1033
+ const assetTarSize = (await fs.stat(assetTarPath)).size;
1076
1034
  await stageConfinedSyncIn({
1077
- files: [{
1078
- sourcePath: localPath,
1079
- targetPath: remoteProjectDir,
1080
- kind: "directory",
1081
- exclude,
1082
- access: "ro",
1083
- }],
1084
- sourceRoots: [localPath],
1085
- targetRoots: [remoteProjectDir],
1086
- progressLabel: label,
1035
+ files,
1036
+ postUploadCommands: [{ command: postUploadCommand }],
1037
+ sourceRoots: [tempDir],
1038
+ targetRoots: [runtimeRootDir],
1039
+ progressLabel: asset.key,
1087
1040
  statusPhase: "config_sync",
1088
- progressBytes: 0,
1041
+ progressBytes: assetTarSize,
1089
1042
  });
1090
- additionalSourceDirs[projectId] = remoteProjectDir;
1091
- }
1092
- catch (error) {
1093
- const message = error instanceof Error ? error.message : String(error);
1094
- // Record the failure as a first-class per-project outcome so the run can count it in the
1095
- // requested-vs-synced accounting and surface the reason on the run result and the run log.
1096
- // The structured slot carries the reason, so no `console.warn` line is needed here.
1097
- additionalFailureSlots[sourceIndex] = { projectId, error: message };
1098
- }
1099
- }));
1100
- });
1101
- // Run every inbound operation through the shared scheduler. It starts the
1102
- // tasks under the client's concurrency permission and the bound, and it
1103
- // settles every started task before it returns. This is the startup barrier:
1104
- // the coordinator returns only after every upload settles.
1105
- const inboundResults = await scheduleSyncOperations(inboundTasks, input.client.allowConcurrentSyncOperations === true, SYNC_OPERATION_CONCURRENCY_LIMIT);
1106
- // Collect the referenced-project failures in stable input order.
1107
- for (const failure of additionalFailureSlots) {
1108
- if (failure) {
1109
- additionalSourceFailures.push(failure);
1110
- }
1111
- }
1112
- // Select a fatal failure in stable operation order. The workspace comes first,
1113
- // then each asset. A referenced-project task never rejects, so a nonfatal
1114
- // outcome never appears here. Raise the first required rejection, so two
1115
- // required failures raise the earlier one.
1116
- for (let index = 0; index < inboundResults.length; index += 1) {
1117
- const result = inboundResults[index];
1118
- if (inboundTaskIsRequired[index] && result.status === "rejected") {
1119
- throw result.reason;
1043
+ }));
1120
1044
  }
1121
- }
1122
- });
1123
- const assetDirs = Object.fromEntries((input.assets ?? []).map((asset) => [asset.key, path.posix.join(runtimeRootDir, asset.key)]));
1124
- return {
1125
- spec: input.spec,
1126
- workspaceLocalDir: input.workspaceLocalDir,
1127
- workspaceRemoteDir,
1128
- runtimeRootDir,
1129
- assetDirs,
1130
- additionalSourceDirs,
1131
- additionalSourceFailures,
1132
- workspaceSyncSnapshot: syncWorkspace && baselineSnapshot
1133
- ? { baseline: baselineSnapshot, gitSnapshot }
1134
- : null,
1135
- restoreWorkspace: async (onProgress) => {
1136
- const restoreSink = onProgress ?? input.onProgress;
1137
- // Build the ordered outbound restore task list. Each task runs one
1138
- // restore from start to end: the workspace restore, or one asset restore.
1139
- // The shared scheduler starts the tasks. When the sync client permits
1140
- // concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
1141
- // tasks active. When the client does not permit concurrency, the scheduler
1142
- // runs one task at a time in this order. The scheduler settles every
1143
- // started task before it returns, so lease teardown never starts while an
1144
- // outbound restore task still writes host data.
1145
- const outboundTasks = [];
1146
- // The workspace restore task runs only when the run syncs the workspace.
1147
- // The task exports the sandbox git history (git-backed workspace), reads
1148
- // the sandbox workspace back, and merges it into the host workspace root.
1149
- // The merge is the only outbound write inside the host workspace root.
1150
- // Every other task writes a disjoint host target: an asset restore writes
1151
- // its own store outside the workspace root. So the workspace task and the
1152
- // asset tasks share one parallel set. Keep any future asset that must write
1153
- // inside the host workspace root out of this set, and run it after the
1154
- // merge. Each task gets its own restore temp directory, so two concurrent
1155
- // tasks never share scratch state.
1156
- if (syncWorkspace) {
1157
- outboundTasks.push(() => runStepSpan("restore.workspace", async () => {
1158
- // Each repository owns its Git history and merge. The parent baseline also
1159
- // records child files so restart recovery has their original merge inputs.
1160
- for (const repository of repositories) {
1161
- const prefix = `${repository.path}/`;
1162
- const localDir = path.join(input.workspaceLocalDir, repository.path);
1163
- if (await fs.realpath(localDir) !== path.join(await fs.realpath(input.workspaceLocalDir), repository.path)) {
1164
- throw new Error("Project repository escaped its workspace");
1045
+ // Stage each referenced (additional) project as a plain, read-only tree in
1046
+ // its OWN isolated remote directory (`project-<projectId>`). An additional
1047
+ // project rides one confined `syncIn` directory mapping — a native directory
1048
+ // transfer, or the base64-tar fallback — with source and target confined to
1049
+ // their own roots. No workspace, git-history, or `.paperclip-runtime`
1050
+ // semantics apply; those stay anchor-only. Per-project failure isolation: one
1051
+ // project's confinement or sync failure logs a warning and is skipped, and
1052
+ // the run plus the other projects continue. Only a project that stages
1053
+ // successfully appears in `additionalSourceDirs`.
1054
+ const additionalSourceList = input.additionalSources ?? [];
1055
+ // Record each referenced-project failure in its input-order slot. Slot order
1056
+ // keeps the failure list stable no matter the task completion order under
1057
+ // concurrency.
1058
+ const additionalFailureSlots = additionalSourceList.map(() => null);
1059
+ additionalSourceList.forEach((source, sourceIndex) => {
1060
+ inboundTaskIsRequired.push(false);
1061
+ inboundTasks.push(() => runStepSpan(`stage.project.${source.projectId}`, async () => {
1062
+ const { localPath, projectId, ignoreResolution } = source;
1063
+ const label = `project-${projectId}`;
1064
+ try {
1065
+ if (!path.posix.isAbsolute(localPath)) {
1066
+ throw new Error(`additional source localPath is not an absolute path: ${localPath}`);
1067
+ }
1068
+ if (projectId.length === 0 ||
1069
+ projectId.includes("/") ||
1070
+ projectId.includes("\\") ||
1071
+ projectId.includes("..")) {
1072
+ throw new Error(`additional source projectId is not a simple path segment: ${projectId}`);
1165
1073
  }
1166
- const nestedExclude = mergeExcludes(repository.snapshot.ignoredPaths, baselineSnapshot.exclude.flatMap((entry) => entry.startsWith(prefix) ? [entry.slice(prefix.length)]
1167
- : entry.startsWith("*/") ? [entry] : []));
1168
- const nested = await prepareSandboxManagedRuntime({
1169
- spec: { ...input.spec, remoteCwd: path.posix.join(workspaceRemoteDir, repository.path) },
1170
- client: input.client,
1171
- adapterKey: input.adapterKey,
1172
- workspaceLocalDir: localDir,
1173
- workspaceInboundMode: "adopt_remote",
1174
- workspaceGitSnapshot: repository.snapshot,
1175
- workspaceExclude: nestedExclude,
1176
- workspaceBaseline: {
1177
- exclude: mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], nestedExclude),
1178
- entries: new Map([...baselineSnapshot.entries]
1179
- .filter(([entry]) => entry.startsWith(prefix))
1180
- .map(([entry, value]) => [entry.slice(prefix.length), value])),
1181
- },
1182
- onRuntimeProgress: input.onRuntimeProgress,
1074
+ // Fail closed: a project whose ignore resolution failed is not staged
1075
+ // at all. Shipping it with only the fixed heavy-directory excludes
1076
+ // would defeat the resolution's purpose.
1077
+ if (ignoreResolution.kind === "failed") {
1078
+ throw new Error(`referenced project ignore resolution failed: ${ignoreResolution.reason}`);
1079
+ }
1080
+ const remoteProjectDir = path.posix.join(runtimeRootDir, label);
1081
+ const exclude = mergeExcludes(additionalSourceBaseExclude, referencedSourceIgnoreExcludeEntries(ignoreResolution));
1082
+ await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to environment");
1083
+ await stageConfinedSyncIn({
1084
+ files: [{
1085
+ sourcePath: localPath,
1086
+ targetPath: remoteProjectDir,
1087
+ kind: "directory",
1088
+ exclude,
1089
+ access: "ro",
1090
+ }],
1091
+ sourceRoots: [localPath],
1092
+ targetRoots: [remoteProjectDir],
1093
+ progressLabel: label,
1094
+ statusPhase: "config_sync",
1095
+ progressBytes: 0,
1183
1096
  });
1184
- await nested.restoreWorkspace(restoreSink);
1097
+ additionalSourceDirs[projectId] = remoteProjectDir;
1185
1098
  }
1186
- await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
1187
- let importedRef = null;
1188
- let importedHead = null;
1189
- let remoteWorkspaceStatus = "dirty";
1190
- try {
1191
- if (gitSnapshot) {
1192
- await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from environment");
1193
- importedRef = createImportedGitRef("sandbox");
1194
- const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle");
1195
- const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt");
1196
- const exportRef = createRemoteGitExportRef("sandbox");
1197
- const localBundlePath = path.join(tempDir, "git-delta.bundle");
1198
- // Export the sandbox history and import it into the host workspace.
1199
- // The delta bundle assumes the host holds the bundle's boundary
1200
- // commit; when the host has been reset far enough that it does not,
1201
- // the import fails on a missing prerequisite. In that case re-export
1202
- // a full, self-contained bundle from the still-live sandbox rather
1203
- // than discard the completed run.
1204
- const exportAndImport = async (forceFullBundle) => {
1205
- await input.client.run(`sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({
1206
- remoteDir: workspaceRemoteDir,
1207
- baseSha: gitSnapshot.headCommit,
1208
- exportRef,
1209
- bundlePath: remoteGitBundle,
1210
- statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath,
1211
- forceFullBundle,
1212
- }))}`, { timeoutMs: input.spec.timeoutMs });
1213
- const gitExport = makeTransferProgress(restoreSink, "Exporting git history", "from", undefined, { sink: input.onRuntimeProgress, phase: "export" });
1099
+ catch (error) {
1100
+ const message = error instanceof Error ? error.message : String(error);
1101
+ // Record the failure as a first-class per-project outcome so the run can count it in the
1102
+ // requested-vs-synced accounting and surface the reason on the run result and the run log.
1103
+ // The structured slot carries the reason, so no `console.warn` line is needed here.
1104
+ additionalFailureSlots[sourceIndex] = { projectId, error: message };
1105
+ }
1106
+ }));
1107
+ });
1108
+ // Run every inbound operation through the shared scheduler. It starts the
1109
+ // tasks under the client's concurrency permission and the bound, and it
1110
+ // settles every started task before it returns. This is the startup barrier:
1111
+ // the coordinator returns only after every upload settles.
1112
+ const inboundResults = await scheduleSyncOperations(inboundTasks, input.client.allowConcurrentSyncOperations === true, SYNC_OPERATION_CONCURRENCY_LIMIT);
1113
+ // Collect the referenced-project failures in stable input order.
1114
+ for (const failure of additionalFailureSlots) {
1115
+ if (failure) {
1116
+ additionalSourceFailures.push(failure);
1117
+ }
1118
+ }
1119
+ // Select a fatal failure in stable operation order. The workspace comes first,
1120
+ // then each asset. A referenced-project task never rejects, so a nonfatal
1121
+ // outcome never appears here. Raise the first required rejection, so two
1122
+ // required failures raise the earlier one.
1123
+ for (let index = 0; index < inboundResults.length; index += 1) {
1124
+ const result = inboundResults[index];
1125
+ if (inboundTaskIsRequired[index] && result.status === "rejected") {
1126
+ throw result.reason;
1127
+ }
1128
+ }
1129
+ });
1130
+ const assetDirs = Object.fromEntries((input.assets ?? []).map((asset) => [asset.key, path.posix.join(runtimeRootDir, asset.key)]));
1131
+ return {
1132
+ spec: input.spec,
1133
+ workspaceLocalDir: input.workspaceLocalDir,
1134
+ workspaceRemoteDir,
1135
+ runtimeRootDir,
1136
+ assetDirs,
1137
+ additionalSourceDirs,
1138
+ additionalSourceFailures,
1139
+ workspaceSyncSnapshot: syncWorkspace && baselineSnapshot
1140
+ ? { baseline: baselineSnapshot, gitSnapshot }
1141
+ : null,
1142
+ cleanupWorkspaceSnapshot,
1143
+ restoreWorkspace: async (onProgress) => {
1144
+ try {
1145
+ const restoreSink = onProgress ?? input.onProgress;
1146
+ // Build the ordered outbound restore task list. Each task runs one
1147
+ // restore from start to end: the workspace restore, or one asset restore.
1148
+ // The shared scheduler starts the tasks. When the sync client permits
1149
+ // concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT
1150
+ // tasks active. When the client does not permit concurrency, the scheduler
1151
+ // runs one task at a time in this order. The scheduler settles every
1152
+ // started task before it returns, so lease teardown never starts while an
1153
+ // outbound restore task still writes host data.
1154
+ const outboundTasks = [];
1155
+ const outboundDiagnostics = [];
1156
+ // The workspace restore task runs only when the run syncs the workspace.
1157
+ // The task exports the sandbox git history (git-backed workspace), reads
1158
+ // the sandbox workspace back, and merges it into the host workspace root.
1159
+ // The merge is the only outbound write inside the host workspace root.
1160
+ // Every other task writes a disjoint host target: an asset restore writes
1161
+ // its own store outside the workspace root. So the workspace task and the
1162
+ // asset tasks share one parallel set. Keep any future asset that must write
1163
+ // inside the host workspace root out of this set, and run it after the
1164
+ // merge. Each task gets its own restore temp directory, so two concurrent
1165
+ // tasks never share scratch state.
1166
+ if (syncWorkspace) {
1167
+ outboundTasks.push(() => withWorkspaceRestoreDiagnostics("workspace", () => runStepSpan("restore.workspace", async () => {
1168
+ // Each repository owns its Git history and merge. The parent baseline also
1169
+ // records child files so restart recovery has their original merge inputs.
1170
+ for (const repository of repositories) {
1171
+ const prefix = `${repository.path}/`;
1172
+ const localDir = path.join(input.workspaceLocalDir, repository.path);
1173
+ if (await fs.realpath(localDir) !== path.join(await fs.realpath(input.workspaceLocalDir), repository.path)) {
1174
+ throw new Error("Project repository escaped its workspace");
1175
+ }
1176
+ const nestedExclude = mergeExcludes(baselineSnapshot.exclude.flatMap((entry) => entry.startsWith(prefix) ? [entry.slice(prefix.length)]
1177
+ : entry.startsWith("*/") ? [entry] : []));
1178
+ const nested = await prepareSandboxManagedRuntime({
1179
+ spec: { ...input.spec, remoteCwd: path.posix.join(workspaceRemoteDir, repository.path) },
1180
+ client: input.client,
1181
+ adapterKey: input.adapterKey,
1182
+ workspaceLocalDir: localDir,
1183
+ workspaceInboundMode: "adopt_remote",
1184
+ workspaceGitSnapshot: repository.snapshot,
1185
+ workspaceExclude: nestedExclude,
1186
+ workspaceBaseline: await selectDirectorySnapshot(baselineSnapshot, {
1187
+ prefix,
1188
+ exclude: mergeExcludes(SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, [...GIT_ARCHIVE_EXCLUDES], [".paperclip-runtime"], nestedExclude),
1189
+ ignoredPaths: repository.snapshot.ignoredPaths,
1190
+ }),
1191
+ onRuntimeProgress: input.onRuntimeProgress,
1192
+ });
1193
+ await nested.restoreWorkspace(restoreSink);
1194
+ }
1195
+ await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
1196
+ let importedRef = null;
1197
+ let importedHead = null;
1198
+ let remoteWorkspaceStatus = "dirty";
1199
+ try {
1200
+ if (gitSnapshot) {
1201
+ await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from environment");
1202
+ importedRef = createImportedGitRef("sandbox");
1203
+ const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle");
1204
+ const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt");
1205
+ const exportRef = createRemoteGitExportRef("sandbox");
1206
+ const localBundlePath = path.join(tempDir, "git-delta.bundle");
1207
+ // Export the sandbox history and import it into the host workspace.
1208
+ // The delta bundle assumes the host holds the bundle's boundary
1209
+ // commit; when the host has been reset far enough that it does not,
1210
+ // the import fails on a missing prerequisite. In that case re-export
1211
+ // a full, self-contained bundle from the still-live sandbox rather
1212
+ // than discard the completed run.
1213
+ const exportAndImport = async (forceFullBundle) => withWorkspaceRestoreStep("git_export", async () => {
1214
+ await input.client.run(`sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({
1215
+ remoteDir: workspaceRemoteDir,
1216
+ baseSha: gitSnapshot.headCommit,
1217
+ exportRef,
1218
+ bundlePath: remoteGitBundle,
1219
+ statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath,
1220
+ forceFullBundle,
1221
+ }))}`, { timeoutMs: input.spec.timeoutMs });
1222
+ const gitExport = makeTransferProgress(restoreSink, "Exporting git history", "from", undefined, { sink: input.onRuntimeProgress, phase: "export" });
1223
+ if (nativeSyncOut) {
1224
+ // Native outbound: the provider copies the bundle straight from
1225
+ // the sandbox into the host restore temp directory. This maps to
1226
+ // one `kind: "file"` mapping. The host does not buffer the full
1227
+ // bundle in RAM and does not move the bytes through the base64
1228
+ // read loop. The git import step below reads the bundle from
1229
+ // `localBundlePath`. The provider transfer reports its own byte
1230
+ // total, so the "Exporting git history" progress still degrades to
1231
+ // start-and-finish (mirrors the workspace restore below), but the
1232
+ // finish line carries the real transferred byte count.
1233
+ const operations = [{
1234
+ operationId: nextSyncOperationId(),
1235
+ files: [{
1236
+ sourcePath: remoteGitBundle,
1237
+ targetPath: localBundlePath,
1238
+ kind: "file",
1239
+ }],
1240
+ }];
1241
+ assertSyncOperationsConfined(operations, {
1242
+ sourceRoots: [runtimeRootDir],
1243
+ targetRoots: [tempDir],
1244
+ });
1245
+ const syncResult = await input.client.syncOut(operations);
1246
+ const transferredBytes = sumSyncResultBytes(syncResult);
1247
+ await gitExport.finish(transferredBytes, transferredBytes);
1248
+ }
1249
+ else {
1250
+ const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
1251
+ const bundleBuffer = toBuffer(bundleBytes);
1252
+ await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
1253
+ await fs.writeFile(localBundlePath, bundleBuffer);
1254
+ }
1255
+ await input.client.remove(remoteGitBundle).catch(() => undefined);
1256
+ if (!forceFullBundle) {
1257
+ remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
1258
+ .then((bytes) => toBuffer(bytes).toString("utf8").trim())
1259
+ .catch(() => "dirty");
1260
+ remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
1261
+ await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
1262
+ }
1263
+ return withWorkspaceRestoreStep("git_import", () => fetchGitBundleIntoLocalRef({
1264
+ localDir: input.workspaceLocalDir,
1265
+ bundlePath: localBundlePath,
1266
+ exportRef,
1267
+ importedRef: importedRef,
1268
+ baseSha: gitSnapshot.headCommit,
1269
+ }));
1270
+ });
1271
+ try {
1272
+ importedHead = await exportAndImport(false);
1273
+ }
1274
+ catch (error) {
1275
+ if (!isMissingGitPrerequisiteError(error))
1276
+ throw error;
1277
+ importedHead = await exportAndImport(true);
1278
+ }
1279
+ }
1280
+ await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from environment");
1281
+ const extractedDir = path.join(tempDir, "workspace");
1214
1282
  if (nativeSyncOut) {
1215
- // Native outbound: the provider copies the bundle straight from
1216
- // the sandbox into the host restore temp directory. This maps to
1217
- // one `kind: "file"` mapping. The host does not buffer the full
1218
- // bundle in RAM and does not move the bytes through the base64
1219
- // read loop. The git import step below reads the bundle from
1220
- // `localBundlePath`. The provider transfer reports its own byte
1221
- // total, so the "Exporting git history" progress still degrades to
1222
- // start-and-finish (mirrors the workspace restore below), but the
1283
+ // Native outbound: the provider materializes the sandbox workspace into
1284
+ // a fresh host directory. It is a clean destroy-then-replace into a
1285
+ // temp dir the orchestrator just created, so it maps exactly to a
1286
+ // generic directory file mapping; the host-side baseline merge below is
1287
+ // unchanged. The provider transfer reports its own byte total, so the
1223
1288
  // finish line carries the real transferred byte count.
1224
1289
  const operations = [{
1225
1290
  operationId: nextSyncOperationId(),
1226
1291
  files: [{
1227
- sourcePath: remoteGitBundle,
1228
- targetPath: localBundlePath,
1229
- kind: "file",
1292
+ sourcePath: workspaceRemoteDir,
1293
+ targetPath: extractedDir,
1294
+ kind: "directory",
1295
+ exclude: workspaceRestoreExclude,
1230
1296
  }],
1231
1297
  }];
1232
1298
  assertSyncOperationsConfined(operations, {
1233
- sourceRoots: [runtimeRootDir],
1234
- targetRoots: [tempDir],
1299
+ sourceRoots: [workspaceRemoteDir],
1300
+ targetRoots: [extractedDir],
1235
1301
  });
1236
- const syncResult = await input.client.syncOut(operations);
1302
+ await fs.mkdir(extractedDir, { recursive: true });
1303
+ const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace", { sink: input.onRuntimeProgress, phase: "restore" });
1304
+ const syncResult = await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut(operations));
1237
1305
  const transferredBytes = sumSyncResultBytes(syncResult);
1238
- await gitExport.finish(transferredBytes, transferredBytes);
1306
+ await workspaceRestore.finish(transferredBytes, transferredBytes);
1239
1307
  }
1240
1308
  else {
1241
- const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
1242
- const bundleBuffer = toBuffer(bundleBytes);
1243
- await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
1244
- await fs.writeFile(localBundlePath, bundleBuffer);
1245
- }
1246
- await input.client.remove(remoteGitBundle).catch(() => undefined);
1247
- if (!forceFullBundle) {
1248
- remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
1249
- .then((bytes) => toBuffer(bytes).toString("utf8").trim())
1250
- .catch(() => "dirty");
1251
- remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
1252
- await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
1253
- }
1254
- return fetchGitBundleIntoLocalRef({
1255
- localDir: input.workspaceLocalDir,
1256
- bundlePath: localBundlePath,
1257
- exportRef,
1258
- importedRef: importedRef,
1259
- baseSha: gitSnapshot.headCommit,
1260
- });
1261
- };
1262
- try {
1263
- importedHead = await exportAndImport(false);
1264
- }
1265
- catch (error) {
1266
- if (!isMissingGitPrerequisiteError(error))
1267
- throw error;
1268
- importedHead = await exportAndImport(true);
1269
- }
1270
- }
1271
- await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from environment");
1272
- const extractedDir = path.join(tempDir, "workspace");
1273
- if (nativeSyncOut) {
1274
- // Native outbound: the provider materializes the sandbox workspace into
1275
- // a fresh host directory. It is a clean destroy-then-replace into a
1276
- // temp dir the orchestrator just created, so it maps exactly to a
1277
- // generic directory file mapping; the host-side baseline merge below is
1278
- // unchanged. The provider transfer reports its own byte total, so the
1279
- // finish line carries the real transferred byte count.
1280
- const operations = [{
1281
- operationId: nextSyncOperationId(),
1282
- files: [{
1283
- sourcePath: workspaceRemoteDir,
1284
- targetPath: extractedDir,
1285
- kind: "directory",
1286
- exclude: workspaceRestoreExclude,
1287
- }],
1288
- }];
1289
- assertSyncOperationsConfined(operations, {
1290
- sourceRoots: [workspaceRemoteDir],
1291
- targetRoots: [extractedDir],
1292
- });
1293
- await fs.mkdir(extractedDir, { recursive: true });
1294
- const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace", { sink: input.onRuntimeProgress, phase: "restore" });
1295
- const syncResult = await input.client.syncOut(operations);
1296
- const transferredBytes = sumSyncResultBytes(syncResult);
1297
- await workspaceRestore.finish(transferredBytes, transferredBytes);
1298
- }
1299
- else {
1300
- const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
1301
- await input.client.run(`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
1302
- remoteDir: workspaceRemoteDir,
1303
- archivePath: remoteWorkspaceTar,
1304
- exclude: workspaceRestoreExclude,
1305
- }))}`, { timeoutMs: input.spec.timeoutMs });
1306
- const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace", { sink: input.onRuntimeProgress, phase: "restore" });
1307
- const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options);
1308
- const archiveBuffer = toBuffer(archiveBytes);
1309
- await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength);
1310
- await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
1311
- const localArchivePath = path.join(tempDir, "workspace.tar");
1312
- await fs.writeFile(localArchivePath, archiveBuffer);
1313
- await extractTarballToDirectory({
1314
- archivePath: localArchivePath,
1315
- localDir: extractedDir,
1316
- });
1317
- }
1318
- const gitHeadToIntegrate = importedHead;
1319
- await mergeDirectoryWithBaseline({
1320
- baseline: repositories.length === 0 ? baselineSnapshot : {
1321
- exclude: workspaceRestoreExclude,
1322
- entries: new Map([...baselineSnapshot.entries].filter(([entry]) => !repositories.some((repo) => entry === repo.path || entry.startsWith(`${repo.path}/`)))),
1323
- },
1324
- sourceDir: extractedDir,
1325
- targetDir: input.workspaceLocalDir,
1326
- beforeApply: gitHeadToIntegrate
1327
- ? async () => {
1328
- await integrateImportedGitHead({
1329
- localDir: input.workspaceLocalDir,
1330
- importedHead: gitHeadToIntegrate,
1309
+ const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
1310
+ await withWorkspaceRestoreStep("workspace_transfer", () => input.client.run(`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
1311
+ remoteDir: workspaceRemoteDir,
1312
+ archivePath: remoteWorkspaceTar,
1313
+ exclude: workspaceRestoreExclude,
1314
+ }))}`, { timeoutMs: input.spec.timeoutMs }));
1315
+ const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace", { sink: input.onRuntimeProgress, phase: "restore" });
1316
+ const archiveBytes = await withWorkspaceRestoreStep("workspace_transfer", () => input.client.readFile(remoteWorkspaceTar, workspaceRestore.options));
1317
+ const archiveBuffer = toBuffer(archiveBytes);
1318
+ await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength);
1319
+ await input.client.remove(remoteWorkspaceTar).catch(() => undefined);
1320
+ const localArchivePath = path.join(tempDir, "workspace.tar");
1321
+ await withWorkspaceRestoreStep("workspace_extract", async () => {
1322
+ await fs.writeFile(localArchivePath, archiveBuffer);
1323
+ await extractTarballToDirectory({
1324
+ archivePath: localArchivePath,
1325
+ localDir: extractedDir,
1326
+ });
1331
1327
  });
1332
1328
  }
1333
- : undefined,
1334
- afterApply: gitSnapshot
1335
- ? async () => {
1336
- await resetLocalGitIndexToHead({
1337
- localDir: input.workspaceLocalDir,
1338
- checkWorkingTreeClean: remoteWorkspaceStatus === "clean",
1329
+ const gitHeadToIntegrate = importedHead;
1330
+ await withWorkspaceRestoreStep("directory_merge", async () => {
1331
+ const mergeBaseline = repositories.length === 0 ? baselineSnapshot : await selectDirectorySnapshot(baselineSnapshot, {
1332
+ omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths: gitSnapshot?.ignoredPaths,
1339
1333
  });
1334
+ try {
1335
+ await mergeDirectoryWithBaseline({
1336
+ baseline: mergeBaseline,
1337
+ sourceDir: extractedDir,
1338
+ targetDir: input.workspaceLocalDir,
1339
+ beforeApply: gitHeadToIntegrate
1340
+ ? async () => {
1341
+ await withWorkspaceRestoreStep("git_integration", () => integrateImportedGitHead({
1342
+ localDir: input.workspaceLocalDir,
1343
+ importedHead: gitHeadToIntegrate,
1344
+ baseline: gitSnapshot ?? undefined,
1345
+ }));
1346
+ }
1347
+ : undefined,
1348
+ afterApply: gitSnapshot
1349
+ ? async () => {
1350
+ await withWorkspaceRestoreStep("index_reset", () => resetLocalGitIndexToHead({
1351
+ localDir: input.workspaceLocalDir,
1352
+ checkWorkingTreeClean: remoteWorkspaceStatus === "clean",
1353
+ }));
1354
+ }
1355
+ : undefined,
1356
+ });
1357
+ }
1358
+ finally {
1359
+ if (mergeBaseline !== baselineSnapshot)
1360
+ await disposeDirectorySnapshot(mergeBaseline);
1361
+ }
1362
+ });
1363
+ }
1364
+ finally {
1365
+ await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing workspace");
1366
+ if (importedRef) {
1367
+ await withWorkspaceRestoreStep("git_ref_cleanup", () => deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef }));
1340
1368
  }
1341
- : undefined,
1369
+ }
1342
1370
  });
1371
+ }), restoreSink, (diagnostic) => { outboundDiagnostics[0] = diagnostic; }));
1372
+ }
1373
+ // One restore task per asset that has a teardown contribution. Each task
1374
+ // reads its asset back through `readFile` and writes a disjoint host
1375
+ // target. Each task gets its own restore temp directory.
1376
+ for (const asset of input.assets ?? []) {
1377
+ if (!asset.restore)
1378
+ continue;
1379
+ const assetRestore = asset.restore;
1380
+ const assetKey = asset.key;
1381
+ const taskIndex = outboundTasks.length;
1382
+ outboundTasks.push(() => withWorkspaceRestoreDiagnostics("asset", () => runStepSpan(`restore.asset.${assetKey}`, async () => {
1383
+ await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
1384
+ await withWorkspaceRestoreStep("asset_restore", () => assetRestore({
1385
+ assetDir: path.posix.join(runtimeRootDir, assetKey),
1386
+ readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
1387
+ tempDir,
1388
+ }));
1389
+ });
1390
+ }), restoreSink, (diagnostic) => { outboundDiagnostics[taskIndex] = diagnostic; }));
1391
+ }
1392
+ // Run every outbound restore through the shared scheduler. It starts the
1393
+ // tasks under the client concurrency permission and the bound, and it
1394
+ // settles every started task before it returns. This is the teardown
1395
+ // barrier.
1396
+ const outboundResults = await scheduleSyncOperations(outboundTasks, input.client.allowConcurrentSyncOperations === true, SYNC_OPERATION_CONCURRENCY_LIMIT);
1397
+ // Every outbound restore is a required operation: a rejection is fatal.
1398
+ // The workspace comes first, then each asset in order. Raise the first
1399
+ // rejection in stable task order, so two failures raise the earlier one.
1400
+ for (const [index, result] of outboundResults.entries()) {
1401
+ if (result.status === "rejected") {
1402
+ recordWorkspaceRestoreDiagnostic(result.reason, outboundDiagnostics[index]);
1403
+ throw result.reason;
1343
1404
  }
1344
- finally {
1345
- await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing workspace");
1346
- if (importedRef) {
1347
- await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef });
1348
- }
1349
- }
1350
- });
1351
- }));
1352
- }
1353
- // One restore task per asset that has a teardown contribution. Each task
1354
- // reads its asset back through `readFile` and writes a disjoint host
1355
- // target. Each task gets its own restore temp directory.
1356
- for (const asset of input.assets ?? []) {
1357
- if (!asset.restore)
1358
- continue;
1359
- const assetRestore = asset.restore;
1360
- const assetKey = asset.key;
1361
- outboundTasks.push(() => runStepSpan(`restore.asset.${assetKey}`, async () => {
1362
- await withTempDir("paperclip-sandbox-restore-", async (tempDir) => {
1363
- await assetRestore({
1364
- assetDir: path.posix.join(runtimeRootDir, assetKey),
1365
- readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)),
1366
- tempDir,
1367
- });
1368
- });
1369
- }));
1370
- }
1371
- // Run every outbound restore through the shared scheduler. It starts the
1372
- // tasks under the client concurrency permission and the bound, and it
1373
- // settles every started task before it returns. This is the teardown
1374
- // barrier.
1375
- const outboundResults = await scheduleSyncOperations(outboundTasks, input.client.allowConcurrentSyncOperations === true, SYNC_OPERATION_CONCURRENCY_LIMIT);
1376
- // Every outbound restore is a required operation: a rejection is fatal.
1377
- // The workspace comes first, then each asset in order. Raise the first
1378
- // rejection in stable task order, so two failures raise the earlier one.
1379
- for (const result of outboundResults) {
1380
- if (result.status === "rejected") {
1381
- throw result.reason;
1405
+ }
1382
1406
  }
1383
- }
1384
- },
1385
- };
1407
+ finally {
1408
+ await cleanupWorkspaceSnapshot();
1409
+ }
1410
+ },
1411
+ };
1412
+ }
1413
+ catch (error) {
1414
+ await cleanupWorkspaceSnapshot();
1415
+ throw error;
1416
+ }
1386
1417
  }
1387
1418
  //# sourceMappingURL=sandbox-managed-runtime.js.map