@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.
- package/README.md +13 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts +4 -0
- package/dist/acpx-engine/ephemeral-session-environment.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.js +29 -0
- package/dist/acpx-engine/ephemeral-session-environment.js.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts +2 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.d.ts.map +1 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js +177 -0
- package/dist/acpx-engine/ephemeral-session-environment.test.js.map +1 -0
- package/dist/acpx-engine/execute.d.ts +2 -6
- package/dist/acpx-engine/execute.d.ts.map +1 -1
- package/dist/acpx-engine/execute.js +125 -62
- package/dist/acpx-engine/execute.js.map +1 -1
- package/dist/acpx-engine/execute.test.js +299 -3
- package/dist/acpx-engine/execute.test.js.map +1 -1
- package/dist/acpx-engine/session-codec.d.ts.map +1 -1
- package/dist/acpx-engine/session-codec.js +1 -0
- package/dist/acpx-engine/session-codec.js.map +1 -1
- package/dist/acpx-engine/settlement-characterization.test.js +105 -0
- package/dist/acpx-engine/settlement-characterization.test.js.map +1 -1
- package/dist/acpx-engine/spawn-smoke.test.js +16 -7
- package/dist/acpx-engine/spawn-smoke.test.js.map +1 -1
- package/dist/acpx-engine/startup-cancellation.d.ts +1 -0
- package/dist/acpx-engine/startup-cancellation.d.ts.map +1 -1
- package/dist/acpx-engine/startup-cancellation.js +7 -2
- package/dist/acpx-engine/startup-cancellation.js.map +1 -1
- package/dist/acpx-engine/terminal-session-failure.d.ts +18 -0
- package/dist/acpx-engine/terminal-session-failure.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.js +106 -0
- package/dist/acpx-engine/terminal-session-failure.js.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts +2 -0
- package/dist/acpx-engine/terminal-session-failure.test.d.ts.map +1 -0
- package/dist/acpx-engine/terminal-session-failure.test.js +139 -0
- package/dist/acpx-engine/terminal-session-failure.test.js.map +1 -0
- package/dist/acpx-engine/turn-characterization.test.js +4 -3
- package/dist/acpx-engine/turn-characterization.test.js.map +1 -1
- package/dist/command-managed-runtime.d.ts +2 -0
- package/dist/command-managed-runtime.d.ts.map +1 -1
- package/dist/command-managed-runtime.js +2 -0
- package/dist/command-managed-runtime.js.map +1 -1
- package/dist/command-redaction.d.ts +2 -0
- package/dist/command-redaction.d.ts.map +1 -1
- package/dist/command-redaction.js +20 -10
- package/dist/command-redaction.js.map +1 -1
- package/dist/command-redaction.test.js +20 -1
- package/dist/command-redaction.test.js.map +1 -1
- package/dist/directory-merge-lock.test.d.ts +2 -0
- package/dist/directory-merge-lock.test.d.ts.map +1 -0
- package/dist/directory-merge-lock.test.js +166 -0
- package/dist/directory-merge-lock.test.js.map +1 -0
- package/dist/execution-phase.d.ts +10 -0
- package/dist/execution-phase.d.ts.map +1 -0
- package/dist/execution-phase.js +27 -0
- package/dist/execution-phase.js.map +1 -0
- package/dist/execution-phase.test.d.ts +2 -0
- package/dist/execution-phase.test.d.ts.map +1 -0
- package/dist/execution-phase.test.js +26 -0
- package/dist/execution-phase.test.js.map +1 -0
- package/dist/execution-target-sandbox.test.js +49 -7
- package/dist/execution-target-sandbox.test.js.map +1 -1
- package/dist/execution-target-stdin-race.test.js +245 -2
- package/dist/execution-target-stdin-race.test.js.map +1 -1
- package/dist/execution-target.d.ts +6 -0
- package/dist/execution-target.d.ts.map +1 -1
- package/dist/execution-target.js +74 -22
- package/dist/execution-target.js.map +1 -1
- package/dist/git-workspace-sync.d.ts +15 -5
- package/dist/git-workspace-sync.d.ts.map +1 -1
- package/dist/git-workspace-sync.js +267 -124
- package/dist/git-workspace-sync.js.map +1 -1
- package/dist/git-workspace-sync.test.js +296 -20
- package/dist/git-workspace-sync.test.js.map +1 -1
- package/dist/github-launcher.d.ts.map +1 -1
- package/dist/github-launcher.js +26 -11
- package/dist/github-launcher.js.map +1 -1
- package/dist/github-launcher.test.js +34 -1
- package/dist/github-launcher.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/paperclip-runner-permissions.d.ts +28 -1
- package/dist/paperclip-runner-permissions.d.ts.map +1 -1
- package/dist/paperclip-runner-permissions.js +9 -1
- package/dist/paperclip-runner-permissions.js.map +1 -1
- package/dist/prompt-sections.test.d.ts +2 -0
- package/dist/prompt-sections.test.d.ts.map +1 -0
- package/dist/prompt-sections.test.js +53 -0
- package/dist/prompt-sections.test.js.map +1 -0
- package/dist/provider-stopped-boundary.d.ts +7 -0
- package/dist/provider-stopped-boundary.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.js +22 -0
- package/dist/provider-stopped-boundary.js.map +1 -0
- package/dist/provider-stopped-boundary.test.d.ts +2 -0
- package/dist/provider-stopped-boundary.test.d.ts.map +1 -0
- package/dist/provider-stopped-boundary.test.js +76 -0
- package/dist/provider-stopped-boundary.test.js.map +1 -0
- package/dist/remote-managed-runtime.d.ts +2 -0
- package/dist/remote-managed-runtime.d.ts.map +1 -1
- package/dist/remote-managed-runtime.js +3 -1
- package/dist/remote-managed-runtime.js.map +1 -1
- package/dist/remote-managed-runtime.test.js +25 -0
- package/dist/remote-managed-runtime.test.js.map +1 -1
- package/dist/sandbox-callback-bridge.d.ts +2 -0
- package/dist/sandbox-callback-bridge.d.ts.map +1 -1
- package/dist/sandbox-callback-bridge.js +34 -10
- package/dist/sandbox-callback-bridge.js.map +1 -1
- package/dist/sandbox-callback-bridge.test.js +106 -3
- package/dist/sandbox-callback-bridge.test.js.map +1 -1
- package/dist/sandbox-managed-runtime.d.ts +3 -0
- package/dist/sandbox-managed-runtime.d.ts.map +1 -1
- package/dist/sandbox-managed-runtime.js +749 -718
- package/dist/sandbox-managed-runtime.js.map +1 -1
- package/dist/sandbox-managed-runtime.test.js +171 -2
- package/dist/sandbox-managed-runtime.test.js.map +1 -1
- package/dist/server-utils.d.ts +43 -1
- package/dist/server-utils.d.ts.map +1 -1
- package/dist/server-utils.js +103 -31
- package/dist/server-utils.js.map +1 -1
- package/dist/server-utils.test.js +235 -45
- package/dist/server-utils.test.js.map +1 -1
- package/dist/session-compaction.d.ts.map +1 -1
- package/dist/session-compaction.js +6 -0
- package/dist/session-compaction.js.map +1 -1
- package/dist/ssh-fixture.test.js +22 -0
- package/dist/ssh-fixture.test.js.map +1 -1
- package/dist/ssh.d.ts +2 -0
- package/dist/ssh.d.ts.map +1 -1
- package/dist/ssh.js +2 -2
- package/dist/ssh.js.map +1 -1
- package/dist/test-fixtures/prompt-context.d.ts +54 -0
- package/dist/test-fixtures/prompt-context.d.ts.map +1 -0
- package/dist/test-fixtures/prompt-context.js +55 -0
- package/dist/test-fixtures/prompt-context.js.map +1 -0
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace-git-stream.d.ts +19 -0
- package/dist/workspace-git-stream.d.ts.map +1 -0
- package/dist/workspace-git-stream.js +99 -0
- package/dist/workspace-git-stream.js.map +1 -0
- package/dist/workspace-git-stream.test.d.ts +2 -0
- package/dist/workspace-git-stream.test.d.ts.map +1 -0
- package/dist/workspace-git-stream.test.js +71 -0
- package/dist/workspace-git-stream.test.js.map +1 -0
- package/dist/workspace-manifest.d.ts +61 -0
- package/dist/workspace-manifest.d.ts.map +1 -0
- package/dist/workspace-manifest.js +257 -0
- package/dist/workspace-manifest.js.map +1 -0
- package/dist/workspace-manifest.test.d.ts +2 -0
- package/dist/workspace-manifest.test.d.ts.map +1 -0
- package/dist/workspace-manifest.test.js +160 -0
- package/dist/workspace-manifest.test.js.map +1 -0
- package/dist/workspace-restore-diagnostics.d.ts +34 -0
- package/dist/workspace-restore-diagnostics.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.js +238 -0
- package/dist/workspace-restore-diagnostics.js.map +1 -0
- package/dist/workspace-restore-diagnostics.test.d.ts +2 -0
- package/dist/workspace-restore-diagnostics.test.d.ts.map +1 -0
- package/dist/workspace-restore-diagnostics.test.js +258 -0
- package/dist/workspace-restore-diagnostics.test.js.map +1 -0
- package/dist/workspace-restore-merge.d.ts +47 -6
- package/dist/workspace-restore-merge.d.ts.map +1 -1
- package/dist/workspace-restore-merge.js +382 -114
- package/dist/workspace-restore-merge.js.map +1 -1
- package/dist/workspace-restore-merge.test.js +253 -4
- package/dist/workspace-restore-merge.test.js.map +1 -1
- package/dist/workspace-restore-result.d.ts +6 -0
- package/dist/workspace-restore-result.d.ts.map +1 -0
- package/dist/workspace-restore-result.js +72 -0
- package/dist/workspace-restore-result.js.map +1 -0
- package/dist/workspace-restore-result.test.d.ts +2 -0
- package/dist/workspace-restore-result.test.d.ts.map +1 -0
- package/dist/workspace-restore-result.test.js +74 -0
- package/dist/workspace-restore-result.test.js.map +1 -0
- package/dist/workspace-restore-teardown.d.ts.map +1 -1
- package/dist/workspace-restore-teardown.js +5 -3
- package/dist/workspace-restore-teardown.js.map +1 -1
- package/dist/workspace-restore-teardown.test.js +24 -0
- package/dist/workspace-restore-teardown.test.js.map +1 -1
- package/node_modules/acpx/dist/live-checkpoint-ClPCSdrW.js +4 -3
- 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
|
-
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
`
|
|
600
|
-
`
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
...
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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:
|
|
861
|
-
targetPath:
|
|
862
|
-
expectedSha256: input.workspaceDurableSeed
|
|
913
|
+
sourcePath: input.workspaceDurableSeed.workspaceArchivePath,
|
|
914
|
+
targetPath: workspaceTarPath,
|
|
915
|
+
expectedSha256: input.workspaceDurableSeed.workspaceArchiveSha256,
|
|
863
916
|
});
|
|
864
917
|
}
|
|
865
918
|
else {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
await
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
936
|
+
if (input.workspaceDurableSeed) {
|
|
877
937
|
await persistDurableSeedArchive({
|
|
878
|
-
sourcePath:
|
|
879
|
-
targetPath: input.workspaceDurableSeed.
|
|
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:
|
|
885
|
-
targetPath:
|
|
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:
|
|
894
|
-
wipeExceptNames: [
|
|
954
|
+
remoteTar: remoteWorkspaceTar,
|
|
955
|
+
wipeExceptNames: gitSnapshot ? null : [...preservedNames],
|
|
895
956
|
}),
|
|
896
957
|
});
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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:
|
|
1041
|
+
progressBytes: assetTarSize,
|
|
1089
1042
|
});
|
|
1090
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
1097
|
+
additionalSourceDirs[projectId] = remoteProjectDir;
|
|
1185
1098
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
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:
|
|
1228
|
-
targetPath:
|
|
1229
|
-
kind: "
|
|
1292
|
+
sourcePath: workspaceRemoteDir,
|
|
1293
|
+
targetPath: extractedDir,
|
|
1294
|
+
kind: "directory",
|
|
1295
|
+
exclude: workspaceRestoreExclude,
|
|
1230
1296
|
}],
|
|
1231
1297
|
}];
|
|
1232
1298
|
assertSyncOperationsConfined(operations, {
|
|
1233
|
-
sourceRoots: [
|
|
1234
|
-
targetRoots: [
|
|
1299
|
+
sourceRoots: [workspaceRemoteDir],
|
|
1300
|
+
targetRoots: [extractedDir],
|
|
1235
1301
|
});
|
|
1236
|
-
|
|
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
|
|
1306
|
+
await workspaceRestore.finish(transferredBytes, transferredBytes);
|
|
1239
1307
|
}
|
|
1240
1308
|
else {
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|