@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
@@ -1,9 +1,33 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { constants as fsConstants, promises as fs } from "node:fs";
4
4
  import path from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+ import { createWorkspaceManifest, WorkspaceManifestMap, workspacePathMatcher } from "./workspace-manifest.js";
5
7
  import { shouldExcludePath } from "./exclude-patterns.js";
6
8
  import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js";
9
+ const ownedDirectorySnapshots = new WeakMap();
10
+ export async function disposeDirectorySnapshot(snapshot) {
11
+ if (!snapshot)
12
+ return;
13
+ if (snapshot.entries instanceof WorkspaceManifestMap) {
14
+ snapshot.entries.close();
15
+ const ownedDirectory = ownedDirectorySnapshots.get(snapshot);
16
+ ownedDirectorySnapshots.delete(snapshot);
17
+ if (ownedDirectory)
18
+ await fs.rm(ownedDirectory, { recursive: true, force: true });
19
+ }
20
+ }
21
+ function parseManifestEntry(value) {
22
+ const result = parseSnapshotEntry(JSON.parse(value));
23
+ if (!result)
24
+ throw new Error("Invalid workspace baseline entry");
25
+ return result;
26
+ }
27
+ /** Call only after the controller validates a persisted manifest's path and digest. */
28
+ export function openDirectorySnapshot(value) {
29
+ return { exclude: value.exclude, entries: new WorkspaceManifestMap(value.entries, parseManifestEntry), ignoredPaths: value.ignoredPaths };
30
+ }
7
31
  function isSafeSnapshotRelativePath(value) {
8
32
  if (!value || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
9
33
  return false;
@@ -30,6 +54,11 @@ function parseSnapshotEntry(value) {
30
54
  return null;
31
55
  }
32
56
  export function serializeDirectorySnapshot(snapshot) {
57
+ if (snapshot.entries instanceof WorkspaceManifestMap)
58
+ return {
59
+ version: 2, exclude: [...snapshot.exclude], entries: snapshot.entries.manifest,
60
+ ...(snapshot.ignoredPaths ? { ignoredPaths: snapshot.ignoredPaths } : {}),
61
+ };
33
62
  return {
34
63
  version: 1,
35
64
  exclude: [...snapshot.exclude],
@@ -65,9 +94,13 @@ export function parseDirectorySnapshot(value) {
65
94
  };
66
95
  }
67
96
  export function directorySnapshotSha256(snapshot) {
68
- return createHash("sha256")
69
- .update(JSON.stringify(serializeDirectorySnapshot(snapshot)))
70
- .digest("hex");
97
+ if (!(snapshot.entries instanceof WorkspaceManifestMap))
98
+ return createHash("sha256")
99
+ .update(JSON.stringify(serializeDirectorySnapshot(snapshot))).digest("hex");
100
+ const digest = createHash("sha256").update("workspace-baseline-v2\0").update(JSON.stringify(snapshot.exclude));
101
+ for (const entry of snapshot.entries)
102
+ digest.update(JSON.stringify(entry)).update("\0");
103
+ return digest.digest("hex");
71
104
  }
72
105
  async function hashFile(filePath) {
73
106
  return await new Promise((resolve, reject) => {
@@ -78,38 +111,25 @@ async function hashFile(filePath) {
78
111
  stream.on("end", () => resolve(hash.digest("hex")));
79
112
  });
80
113
  }
81
- async function walkDirectory(root, exclude, relative = "", out = new Map()) {
114
+ async function* walkDirectory(root, exclude, ignored, relative = "") {
82
115
  const current = relative ? path.join(root, relative) : root;
83
- const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
84
- entries.sort((left, right) => left.name.localeCompare(right.name));
85
- for (const entry of entries) {
116
+ for await (const entry of await fs.opendir(current)) {
86
117
  const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
87
- if (shouldExcludePath(nextRelative, exclude))
118
+ if (shouldExcludePath(nextRelative, exclude) || ignored.matches(nextRelative))
88
119
  continue;
89
120
  const fullPath = path.join(root, nextRelative);
90
121
  const stats = await fs.lstat(fullPath);
91
- if (!stats.isDirectory() && !stats.isSymbolicLink() && !stats.isFile()) {
92
- continue;
93
- }
94
122
  if (stats.isDirectory()) {
95
- out.set(nextRelative, { kind: "dir" });
96
- await walkDirectory(root, exclude, nextRelative, out);
97
- continue;
123
+ yield [nextRelative, { kind: "dir" }];
124
+ yield* walkDirectory(root, exclude, ignored, nextRelative);
98
125
  }
99
- if (stats.isSymbolicLink()) {
100
- out.set(nextRelative, {
101
- kind: "symlink",
102
- target: await fs.readlink(fullPath),
103
- });
104
- continue;
126
+ else if (stats.isSymbolicLink()) {
127
+ yield [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
128
+ }
129
+ else if (stats.isFile()) {
130
+ yield [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
105
131
  }
106
- out.set(nextRelative, {
107
- kind: "file",
108
- mode: stats.mode,
109
- hash: await hashFile(fullPath),
110
- });
111
132
  }
112
- return out;
113
133
  }
114
134
  async function readSnapshotEntry(root, relative) {
115
135
  const fullPath = path.join(root, relative);
@@ -151,7 +171,69 @@ function entriesMatch(left, right) {
151
171
  }
152
172
  return false;
153
173
  }
154
- const LOCK_STALE_MS = 30_000;
174
+ const LOCK_WAIT_MS = 30_000;
175
+ const LOCK_DIAGNOSTIC_READ_TIMEOUT_MS = 100;
176
+ const activeDirectoryMergeLocks = new Set();
177
+ const MAX_LOCK_DIAGNOSTIC_AGE_MS = 7 * 24 * 60 * 60 * 1000;
178
+ /** Evidence only: neither process age nor this module's holder set can prove
179
+ * that a lock in another process or PID namespace is safe to reclaim. */
180
+ async function directoryMergeLockDiagnostics(lockDir, waitMs, ownerPath) {
181
+ const diagnostics = {
182
+ ownerState: "unknown",
183
+ knownLocalHolder: activeDirectoryMergeLocks.has(lockDir),
184
+ waitMs: Math.min(MAX_LOCK_DIAGNOSTIC_AGE_MS, Math.max(0, Math.floor(waitMs))),
185
+ };
186
+ const controller = new AbortController();
187
+ let timeout;
188
+ try {
189
+ // Abort is best effort: race the read as well so a stalled filesystem
190
+ // cannot keep the original lock timeout from reaching its caller.
191
+ const raw = await Promise.race([
192
+ fs.readFile(ownerPath, { encoding: "utf8", signal: controller.signal }),
193
+ new Promise((resolve) => {
194
+ timeout = setTimeout(() => resolve(undefined), LOCK_DIAGNOSTIC_READ_TIMEOUT_MS);
195
+ }),
196
+ ]);
197
+ if (raw === undefined)
198
+ return diagnostics;
199
+ let owner;
200
+ try {
201
+ owner = JSON.parse(raw);
202
+ }
203
+ catch {
204
+ diagnostics.ownerState = "invalid";
205
+ return diagnostics;
206
+ }
207
+ if (!owner || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
208
+ diagnostics.ownerState = "invalid";
209
+ return diagnostics;
210
+ }
211
+ const pid = owner.pid;
212
+ diagnostics.ownerSameProcess = pid === process.pid;
213
+ try {
214
+ process.kill(pid, 0);
215
+ diagnostics.ownerState = "alive";
216
+ }
217
+ catch (error) {
218
+ diagnostics.ownerState = error.code === "ESRCH" ? "dead" : "unknown";
219
+ }
220
+ const createdAt = typeof owner.createdAt === "string" ? Date.parse(owner.createdAt) : NaN;
221
+ const ageMs = Date.now() - createdAt;
222
+ if (Number.isFinite(ageMs) && ageMs >= 0) {
223
+ diagnostics.ownerAgeMs = Math.min(MAX_LOCK_DIAGNOSTIC_AGE_MS, Math.floor(ageMs));
224
+ if (pid === process.pid)
225
+ diagnostics.ownerPredatesProcess = ageMs > process.uptime() * 1000 + 1000;
226
+ }
227
+ }
228
+ catch (error) {
229
+ diagnostics.ownerState = error.code === "ENOENT" ? "missing" : "unknown";
230
+ }
231
+ finally {
232
+ clearTimeout(timeout);
233
+ controller.abort();
234
+ }
235
+ return diagnostics;
236
+ }
155
237
  /**
156
238
  * The stable `code` a lock-timeout error carries, so a caller can identify it
157
239
  * without matching on the error message text (the message embeds the lock
@@ -163,8 +245,9 @@ export const WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE = "ERR_WORKSPACE_RESTORE_LOCK_T
163
245
  * `EACCES` and `EPERM` to a permission failure, the merge-lock timeout
164
246
  * (matched by {@link WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE}, never by the error
165
247
  * message text) to a lock-timeout failure, and every other error to a generic
166
- * failure. Never reads or returns `Error.message`, a filesystem path, or a
167
- * process id.
248
+ * failure. The known Daytona confinement diagnostic also identifies unsafe
249
+ * archives across plugin transports that retain only a message. Never returns
250
+ * raw messages, paths or process IDs.
168
251
  */
169
252
  export function classifyWorkspaceRestoreFailure(error) {
170
253
  const code = error && typeof error === "object" ? error.code : undefined;
@@ -172,6 +255,16 @@ export function classifyWorkspaceRestoreFailure(error) {
172
255
  return "restore_permission_denied";
173
256
  if (code === WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE)
174
257
  return "restore_lock_timeout";
258
+ const message = error instanceof Error ? error.message : "";
259
+ const archiveRefused = /Daytona syncOut refusing (?:tarball (?:with an unparseable entry listing|(?:link whose target|member that) escapes the extraction dir)|unparseable or ambiguous (?:sym|hard)link entry)/.test(message);
260
+ const outboundPathRefused = /Daytona sync source path (?:is not a confined absolute path|escapes the workspace remote dir):/.test(message);
261
+ // These are the fail-closed guard's own exit codes. Transport/command failures
262
+ // with other exit codes retain the existing transient failure policy.
263
+ const outboundGuardRefused = /Daytona outbound symlink-escape guard command failed \(exit (?:40|41|42|44|45)\)/.test(message);
264
+ if (code === "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" ||
265
+ archiveRefused || outboundPathRefused || outboundGuardRefused) {
266
+ return "restore_unsafe_archive";
267
+ }
175
268
  return "restore_failed";
176
269
  }
177
270
  /**
@@ -188,67 +281,99 @@ export function describeWorkspaceRestoreFailure(code) {
188
281
  return "the restore could not write to the workspace (permission denied)";
189
282
  case "restore_lock_timeout":
190
283
  return "the restore timed out waiting for the workspace merge lock";
284
+ case "restore_unsafe_archive":
285
+ return "the archive contains an unsafe link or path; workspace repair is required";
191
286
  case "restore_failed":
192
287
  return "the restore failed";
193
288
  }
194
289
  }
195
- async function isLockStale(lockDir) {
196
- try {
197
- const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8");
198
- const owner = JSON.parse(raw);
199
- const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null;
200
- if (pid === null) {
201
- // Owner record is unparseable / missing pid — treat as stale.
202
- return true;
203
- }
204
- try {
205
- process.kill(pid, 0);
206
- return false;
207
- }
208
- catch {
209
- return true;
290
+ async function acquireDirectoryMergeLock(lockDir, operation, waitMs = LOCK_WAIT_MS) {
291
+ const startedAt = performance.now();
292
+ const deadline = Date.now() + waitMs;
293
+ const databasePath = `${lockDir}.sqlite`;
294
+ const ownerPath = `${lockDir}.owner.json`;
295
+ async function waitForLock(diagnosticOwnerPath) {
296
+ if (Date.now() >= deadline) {
297
+ const timeoutError = new Error(`Timed out waiting for workspace restore lock at ${lockDir}`);
298
+ timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE;
299
+ // Keep the original timeout if the diagnostic read itself fails.
300
+ timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt, diagnosticOwnerPath).catch(() => undefined);
301
+ if (operation && timeoutError.workspaceRestoreLock)
302
+ timeoutError.workspaceRestoreLock.operation = operation;
303
+ throw timeoutError;
210
304
  }
305
+ await new Promise((resolve) => setTimeout(resolve, 50));
211
306
  }
212
- catch {
213
- // owner.json is missing or unreadable. A live holder also passes through
214
- // this exact state, briefly, between its own `fs.mkdir(lockDir)` and its
215
- // `fs.writeFile(owner.json)` below. Reading "missing" as "stale" here would
216
- // let a concurrent acquirer delete a live holder's lock directory during
217
- // that window. Mirror the materializePaperclipSkillCopy lock pattern: fall
218
- // back to the lock directory's own mtime, and only call it stale once the
219
- // directory itself has outlived the stale threshold.
220
- const stat = await fs.stat(lockDir).catch(() => null);
221
- return !stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS;
307
+ // SQLite's RESERVED file lock is the authority, including across processes
308
+ // and PID namespaces. It is released by the OS on a crash. The empty database
309
+ // is permanent: unlinking it would let contenders lock different inodes.
310
+ // node:sqlite is already required for workspace manifests; no native add-on
311
+ // or external flock command is needed on macOS, Linux, or Windows.
312
+ const databaseStat = await fs.lstat(databasePath).catch((error) => {
313
+ if (error.code === "ENOENT")
314
+ return null;
315
+ throw error;
316
+ });
317
+ if (databaseStat && (!databaseStat.isFile() || databaseStat.isSymbolicLink() || databaseStat.nlink !== 1)) {
318
+ throw new Error("Directory merge lock database is not a plain, unshared file.");
222
319
  }
223
- }
224
- async function acquireDirectoryMergeLock(lockDir) {
225
- const deadline = Date.now() + LOCK_STALE_MS;
226
- while (true) {
320
+ // Let SQLite create and manage every descriptor for this inode. On POSIX,
321
+ // closing a raw fs.open descriptor could release another connection's locks.
322
+ // The parent is private (0700), including while a new file is chmodded.
323
+ const database = new DatabaseSync(databasePath, { allowExtension: false });
324
+ try {
325
+ await fs.chmod(databasePath, 0o600);
326
+ // Never block the event loop while another async operation holds the lock.
327
+ database.exec("PRAGMA busy_timeout=0;");
328
+ while (true) {
329
+ try {
330
+ database.exec("BEGIN IMMEDIATE;");
331
+ break;
332
+ }
333
+ catch (error) {
334
+ const code = error.errcode;
335
+ if (typeof code !== "number" || (code & 0xff) !== 5)
336
+ throw error; // SQLITE_BUSY
337
+ await waitForLock(ownerPath);
338
+ }
339
+ }
340
+ // Old processes do not participate in the SQLite protocol. Never infer
341
+ // that a legacy owner is dead from PID existence, age, or missing metadata.
342
+ // Drain old writers before upgrading. A leftover legacy directory requires
343
+ // explicit offline cleanup; a live legacy holder can still release normally.
344
+ while (await fs.lstat(lockDir).then(() => true, (error) => {
345
+ if (error.code === "ENOENT")
346
+ return false;
347
+ throw error;
348
+ }))
349
+ await waitForLock(path.join(lockDir, "owner.json"));
350
+ const owner = await fs.open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW, 0o600);
227
351
  try {
228
- await fs.mkdir(lockDir);
229
- await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
230
- return async () => {
231
- await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
232
- };
352
+ await owner.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
233
353
  }
234
- catch (error) {
235
- const code = error && typeof error === "object" ? error.code : null;
236
- if (code !== "EEXIST")
237
- throw error;
238
- // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash),
239
- // the lockDir would otherwise persist forever and stall restores. Mirror
240
- // the materializePaperclipSkillCopy lock pattern — remove and retry.
241
- if (await isLockStale(lockDir)) {
242
- await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
243
- continue;
354
+ finally {
355
+ await owner.close();
356
+ }
357
+ activeDirectoryMergeLocks.add(lockDir);
358
+ let released = false;
359
+ return async () => {
360
+ if (released)
361
+ return;
362
+ released = true;
363
+ try {
364
+ // This sidecar is diagnostic only. A failed removal cannot retain
365
+ // ownership, and the next holder replaces it while holding the DB lock.
366
+ await fs.unlink(ownerPath).catch(() => undefined);
244
367
  }
245
- if (Date.now() >= deadline) {
246
- const timeoutError = new Error(`Timed out waiting for workspace restore lock at ${lockDir}`);
247
- timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE;
248
- throw timeoutError;
368
+ finally {
369
+ activeDirectoryMergeLocks.delete(lockDir);
370
+ database.close();
249
371
  }
250
- await new Promise((resolve) => setTimeout(resolve, 50));
251
- }
372
+ };
373
+ }
374
+ catch (error) {
375
+ database.close();
376
+ throw error;
252
377
  }
253
378
  }
254
379
  const DIRECTORY_MERGE_LOCK_ROOT_MODE = 0o700;
@@ -316,13 +441,16 @@ async function resolveDirectoryMergeLockRoot(env = process.env) {
316
441
  }
317
442
  return lockRoot;
318
443
  }
319
- export async function withDirectoryMergeLock(targetDir, fn, env = process.env) {
444
+ export async function withDirectoryMergeLock(targetDir, fn, env = process.env, diagnosticOperation,
445
+ // Test seam only: overrides how long acquisition waits before it reports a
446
+ // timeout. Production callers must omit this and keep the real budget.
447
+ waitMs = LOCK_WAIT_MS) {
320
448
  // Canonicalize before we hash or lock: a retargeted symlink must not let the
321
449
  // lock protect one directory while the caller mutates another.
322
450
  const canonicalTargetDir = await fs.realpath(targetDir);
323
451
  const lockRoot = await resolveDirectoryMergeLockRoot(env);
324
452
  const lockKey = createHash("sha256").update(canonicalTargetDir).digest("hex");
325
- const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`));
453
+ const releaseLock = await acquireDirectoryMergeLock(path.join(lockRoot, `${lockKey}.lock`), diagnosticOperation, waitMs);
326
454
  try {
327
455
  return await fn(canonicalTargetDir);
328
456
  }
@@ -345,50 +473,190 @@ async function copySnapshotEntry(sourceDir, targetDir, relative, entry) {
345
473
  return;
346
474
  }
347
475
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
348
- await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
349
476
  if (entry.kind === "symlink") {
477
+ await fs.rm(targetPath, { recursive: true, force: true });
350
478
  await fs.symlink(entry.target, targetPath);
351
479
  return;
352
480
  }
353
- await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
354
- await fs.copyFile(sourcePath, targetPath);
355
- });
356
- await fs.chmod(targetPath, entry.mode);
481
+ // An interrupted restore must not leave a truncated current file. Keep the
482
+ // incoming tree until its owner records success; exact retries deduplicate.
483
+ const temporary = path.join(path.dirname(targetPath), `.paperclip-merge-${randomUUID()}`);
484
+ try {
485
+ await fs.copyFile(sourcePath, temporary, fsConstants.COPYFILE_FICLONE).catch(async () => {
486
+ await fs.copyFile(sourcePath, temporary);
487
+ });
488
+ await fs.chmod(temporary, entry.mode);
489
+ const file = await fs.open(temporary, "r");
490
+ try {
491
+ await file.sync();
492
+ }
493
+ finally {
494
+ await file.close();
495
+ }
496
+ const existing = await fs.lstat(targetPath).catch(() => null);
497
+ if (existing?.isDirectory())
498
+ await fs.rm(targetPath, { recursive: true, force: true });
499
+ await fs.rename(temporary, targetPath);
500
+ }
501
+ finally {
502
+ await fs.rm(temporary, { force: true });
503
+ }
357
504
  }
358
505
  export async function captureDirectorySnapshot(rootDir, options = {}) {
359
506
  const exclude = [...new Set(options.exclude ?? [])];
360
- return {
361
- exclude,
362
- entries: await walkDirectory(rootDir, exclude),
363
- };
507
+ const ignored = workspacePathMatcher(options.ignoredPaths);
508
+ let writer = null;
509
+ try {
510
+ writer = options.diskBacked ? await createWorkspaceManifest("paperclip-workspace-baseline-") : null;
511
+ const memory = new Map();
512
+ for await (const [relative, entry] of walkDirectory(rootDir, exclude, ignored)) {
513
+ if (writer)
514
+ writer.add("baseline", relative, JSON.stringify(entry));
515
+ else
516
+ memory.set(relative, entry);
517
+ }
518
+ const manifest = writer?.paths("baseline");
519
+ writer?.close();
520
+ const snapshot = {
521
+ exclude, ignoredPaths: options.ignoredPaths,
522
+ entries: manifest ? new WorkspaceManifestMap(manifest, parseManifestEntry) : memory,
523
+ };
524
+ if (writer)
525
+ ownedDirectorySnapshots.set(snapshot, path.dirname(writer.filePath));
526
+ return snapshot;
527
+ }
528
+ catch (error) {
529
+ writer?.close(false);
530
+ if (writer)
531
+ await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
532
+ throw error;
533
+ }
534
+ finally {
535
+ ignored.close();
536
+ }
364
537
  }
365
- export async function mergeDirectoryWithBaseline(input) {
366
- const source = await captureDirectorySnapshot(input.sourceDir, { exclude: input.baseline.exclude });
367
- await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => {
368
- await input.beforeApply?.();
369
- const current = await captureDirectorySnapshot(canonicalTargetDir, { exclude: input.baseline.exclude });
370
- const deletedLeafEntries = [...input.baseline.entries.entries()]
371
- .filter(([relative, entry]) => entry.kind !== "dir" && !source.entries.has(relative))
372
- .sort(([left], [right]) => right.length - left.length);
373
- for (const [relative, baselineEntry] of deletedLeafEntries) {
374
- if (!entriesMatch(current.entries.get(relative), baselineEntry))
538
+ /** A disk-backed subset for independent nested-repository merges. */
539
+ export async function selectDirectorySnapshot(snapshot, options) {
540
+ const writer = await createWorkspaceManifest("paperclip-workspace-baseline-");
541
+ try {
542
+ for (const [relative, entry] of snapshot.entries) {
543
+ if (options.prefix && !relative.startsWith(options.prefix))
544
+ continue;
545
+ if (options.omit?.some((omit) => relative === omit || relative.startsWith(`${omit}/`)))
375
546
  continue;
376
- await fs.rm(path.join(canonicalTargetDir, relative), { recursive: true, force: true }).catch(() => undefined);
547
+ writer.add("baseline", options.prefix ? relative.slice(options.prefix.length) : relative, JSON.stringify(entry));
377
548
  }
378
- const deletedDirs = [...input.baseline.entries.entries()]
379
- .filter(([relative, entry]) => entry.kind === "dir" && !source.entries.has(relative))
380
- .sort(([left], [right]) => right.length - left.length);
381
- for (const [relative] of deletedDirs) {
382
- await fs.rmdir(path.join(canonicalTargetDir, relative)).catch(() => undefined);
549
+ const result = { exclude: options.exclude, ignoredPaths: options.ignoredPaths,
550
+ entries: new WorkspaceManifestMap(writer.paths("baseline"), parseManifestEntry) };
551
+ writer.close();
552
+ ownedDirectorySnapshots.set(result, path.dirname(writer.filePath));
553
+ return result;
554
+ }
555
+ catch (error) {
556
+ writer.close(false);
557
+ await fs.rm(path.dirname(writer.filePath), { recursive: true, force: true });
558
+ throw error;
559
+ }
560
+ }
561
+ function orderedEntries(snapshot, reverse = false) {
562
+ if (snapshot.entries instanceof WorkspaceManifestMap)
563
+ return snapshot.entries.entries(reverse);
564
+ return [...snapshot.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) * (reverse ? -1 : 1));
565
+ }
566
+ export class DirectoryMergeConflict extends Error {
567
+ paths;
568
+ code = "DIRECTORY_MERGE_CONFLICT";
569
+ constructor(paths) {
570
+ super("Directory contents changed concurrently");
571
+ this.paths = paths;
572
+ }
573
+ }
574
+ /** Preflight the entire delta before writing. Identical replays are safe after
575
+ * an interrupted apply; unrelated edits are left alone. No history is retained. */
576
+ export function directoryMergeConflicts(baseline, source, current) {
577
+ const same = (a, b) => (!a && !b) || entriesMatch(a, b);
578
+ const conflicts = new Set();
579
+ function* changedPaths() {
580
+ for (const [name] of baseline.entries)
581
+ yield name;
582
+ for (const [name] of source.entries)
583
+ if (!baseline.entries.has(name))
584
+ yield name;
585
+ }
586
+ for (const relative of changedPaths()) {
587
+ const before = baseline.entries.get(relative);
588
+ const incoming = source.entries.get(relative);
589
+ const present = current.entries.get(relative);
590
+ if (same(before, incoming) || same(incoming, present))
591
+ continue;
592
+ if (!same(before, present))
593
+ conflicts.add(relative);
594
+ // A parent removed/replaced by another writer must never be traversed.
595
+ for (let parent = path.posix.dirname(relative); parent !== "."; parent = path.posix.dirname(parent)) {
596
+ if (current.entries.get(parent)?.kind !== "dir" &&
597
+ !same(current.entries.get(parent), baseline.entries.get(parent)))
598
+ conflicts.add(parent);
383
599
  }
384
- const changedSourceEntries = [...source.entries.entries()]
385
- .filter(([relative, entry]) => !entriesMatch(input.baseline.entries.get(relative), entry))
386
- .sort(([left], [right]) => left.localeCompare(right));
387
- for (const [relative, entry] of changedSourceEntries) {
388
- await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
600
+ }
601
+ // Stream each current entry once. A replacement must not remove children
602
+ // omitted from the baseline, including excluded or newly created files.
603
+ for (const [child, entry] of current.entries) {
604
+ if (same(entry, baseline.entries.get(child)) || same(entry, source.entries.get(child)))
605
+ continue;
606
+ for (let parent = path.posix.dirname(child); parent !== "."; parent = path.posix.dirname(parent)) {
607
+ if (baseline.entries.get(parent)?.kind === "dir" && source.entries.get(parent)?.kind !== "dir") {
608
+ conflicts.add(child);
609
+ break;
610
+ }
389
611
  }
390
- await input.afterApply?.();
391
- });
612
+ }
613
+ return [...conflicts].sort();
614
+ }
615
+ export async function mergeDirectoryWithBaseline(input) {
616
+ const options = { exclude: input.baseline.exclude, ignoredPaths: input.baseline.ignoredPaths, diskBacked: true };
617
+ const source = input.snapshots?.source ?? await captureDirectorySnapshot(input.sourceDir, options);
618
+ try {
619
+ await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => {
620
+ await input.beforeApply?.();
621
+ // Strict preflight must see excluded children before a directory is
622
+ // replaced. The merge still applies only the filtered source/baseline.
623
+ const current = input.snapshots?.current ?? await captureDirectorySnapshot(canonicalTargetDir, input.conflictPolicy === "reject" ? { exclude: [], diskBacked: true } : options);
624
+ try {
625
+ if (input.conflictPolicy === "reject") {
626
+ const conflicts = directoryMergeConflicts(input.baseline, source, current);
627
+ if (conflicts.length)
628
+ throw new DirectoryMergeConflict(conflicts);
629
+ }
630
+ for (const [relative, baselineEntry] of orderedEntries(input.baseline)) {
631
+ if (baselineEntry.kind === "dir" || source.entries.has(relative))
632
+ continue;
633
+ if (!entriesMatch(current.entries.get(relative), baselineEntry))
634
+ continue;
635
+ await fs.rm(path.join(canonicalTargetDir, relative), { recursive: true, force: true });
636
+ }
637
+ // Reverse path order visits descendants before their parent directory.
638
+ for (const [relative, entry] of orderedEntries(input.baseline, true)) {
639
+ if (entry.kind === "dir" && !source.entries.has(relative))
640
+ await fs.rmdir(path.join(canonicalTargetDir, relative)).catch((error) => {
641
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "ENOTDIR")
642
+ throw error;
643
+ });
644
+ }
645
+ for (const [relative, entry] of orderedEntries(source)) {
646
+ if (!entriesMatch(input.baseline.entries.get(relative), entry) &&
647
+ !(input.conflictPolicy === "reject" && entriesMatch(current.entries.get(relative), entry)))
648
+ await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
649
+ }
650
+ await input.afterApply?.();
651
+ }
652
+ finally {
653
+ await disposeDirectorySnapshot(current);
654
+ }
655
+ });
656
+ }
657
+ finally {
658
+ await disposeDirectorySnapshot(source);
659
+ }
392
660
  }
393
661
  export async function directoryEntryMatchesBaseline(rootDir, relative, baselineEntry) {
394
662
  return entriesMatch(await readSnapshotEntry(rootDir, relative), baselineEntry);