amalgm 0.1.239 → 0.1.241

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 (87) hide show
  1. package/lib/shared-realtime-tunnel.js +18 -4
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
  6. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  7. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  8. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  9. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  10. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  11. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  12. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  13. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  14. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  15. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  16. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  17. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  18. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  19. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  20. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  21. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  22. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  41. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  42. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  43. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  44. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  45. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  46. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  48. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  49. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  50. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  67. package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
  68. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  69. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  70. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  72. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  73. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  74. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  83. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  86. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  87. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -0,0 +1,3766 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Durable physical plan for workspace-tree snapshots.
5
+ *
6
+ * The tree database and replica cursor are control records. Before either can
7
+ * move, every destructive filesystem action is named here with its exact
8
+ * source identity and a deterministic, same-filesystem staging path. A crash
9
+ * can therefore resume the one plan instead of guessing from whatever paths
10
+ * happen to exist after restart.
11
+ */
12
+
13
+ const crypto = require('crypto');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { shouldEnroll } = require('./tree/inclusion');
17
+ const { isPathAbsentError } = require('./tree/path-state');
18
+ const { openLocalDb } = require('../state/db');
19
+ const nativeFileTransaction = require('./native-file-transaction/client');
20
+
21
+ const CONTRACT = 'workspace-tree-materialization@1';
22
+ const OPERATION_CONTRACT = 'workspace-tree-operation-materialization@1';
23
+
24
+ function ensureSchema(database = openLocalDb()) {
25
+ database.exec(`
26
+ CREATE TABLE IF NOT EXISTS workspace_tree_materializations (
27
+ resource_id TEXT NOT NULL,
28
+ snapshot_version INTEGER NOT NULL,
29
+ snapshot_checksum TEXT NOT NULL,
30
+ local_path TEXT NOT NULL,
31
+ plan_json TEXT NOT NULL,
32
+ materialization_state TEXT NOT NULL DEFAULT 'prepared'
33
+ CHECK (materialization_state IN ('prepared', 'crossed', 'committed')),
34
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
35
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
36
+ PRIMARY KEY(resource_id, snapshot_version)
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS workspace_tree_operation_materializations (
40
+ resource_id TEXT NOT NULL,
41
+ mutation_id TEXT NOT NULL,
42
+ official_version INTEGER NOT NULL,
43
+ plan_json TEXT NOT NULL,
44
+ materialization_state TEXT NOT NULL DEFAULT 'prepared'
45
+ CHECK (materialization_state IN ('prepared', 'crossed', 'committed')),
46
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
47
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
48
+ PRIMARY KEY(resource_id, mutation_id)
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS workspace_tree_operation_successors (
52
+ successor_seq INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ resource_id TEXT NOT NULL,
54
+ owner_mutation_id TEXT NOT NULL,
55
+ successor_mutation_id TEXT NOT NULL,
56
+ operation_json TEXT NOT NULL,
57
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
58
+ UNIQUE(resource_id, owner_mutation_id, successor_mutation_id)
59
+ );
60
+ `);
61
+ }
62
+
63
+ function normalize(row) {
64
+ if (!row) return null;
65
+ return {
66
+ resourceId: row.resource_id,
67
+ snapshotVersion: Number(row.snapshot_version),
68
+ snapshotChecksum: row.snapshot_checksum,
69
+ localPath: row.local_path,
70
+ plan: JSON.parse(row.plan_json),
71
+ state: row.materialization_state,
72
+ createdAt: row.created_at,
73
+ updatedAt: row.updated_at,
74
+ };
75
+ }
76
+
77
+ function list(database = openLocalDb(), resourceId = null) {
78
+ ensureSchema(database);
79
+ const rows = resourceId
80
+ ? database.prepare(`
81
+ SELECT * FROM workspace_tree_materializations
82
+ WHERE resource_id = ?
83
+ ORDER BY snapshot_version ASC
84
+ `).all(resourceId)
85
+ : database.prepare(`
86
+ SELECT * FROM workspace_tree_materializations
87
+ ORDER BY created_at ASC, resource_id ASC, snapshot_version ASC
88
+ `).all();
89
+ return rows.map(normalize);
90
+ }
91
+
92
+ function get(resourceId, snapshotVersion, database = openLocalDb()) {
93
+ ensureSchema(database);
94
+ return normalize(database.prepare(`
95
+ SELECT * FROM workspace_tree_materializations
96
+ WHERE resource_id = ? AND snapshot_version = ?
97
+ `).get(resourceId, snapshotVersion));
98
+ }
99
+
100
+ function insert(plan, database = openLocalDb()) {
101
+ ensureSchema(database);
102
+ const body = JSON.stringify(plan);
103
+ database.prepare(`
104
+ INSERT INTO workspace_tree_materializations (
105
+ resource_id, snapshot_version, snapshot_checksum, local_path, plan_json
106
+ ) VALUES (?, ?, ?, ?, ?)
107
+ ON CONFLICT(resource_id, snapshot_version) DO NOTHING
108
+ `).run(
109
+ plan.resourceId,
110
+ plan.snapshotVersion,
111
+ plan.snapshotChecksum,
112
+ plan.rootPath,
113
+ body,
114
+ );
115
+ const held = get(plan.resourceId, plan.snapshotVersion, database);
116
+ if (
117
+ !held
118
+ || held.snapshotChecksum !== plan.snapshotChecksum
119
+ || JSON.stringify(held.plan) !== body
120
+ ) {
121
+ throw new Error(
122
+ `workspace tree snapshot ${plan.resourceId}@${plan.snapshotVersion} changed its physical plan`,
123
+ );
124
+ }
125
+ return held;
126
+ }
127
+
128
+ function normalizedExcludedPrefixes(values = []) {
129
+ const normalized = Array.from(new Set(values.map((value) => (
130
+ String(value || '').split('/').filter(Boolean).join('/')
131
+ )).filter(Boolean))).sort((left, right) => (
132
+ left.split('/').length - right.split('/').length
133
+ || left.localeCompare(right)
134
+ ));
135
+ const roots = [];
136
+ for (const candidate of normalized) {
137
+ if (roots.some((root) => (
138
+ candidate === root || candidate.startsWith(`${root}/`)
139
+ ))) continue;
140
+ roots.push(candidate);
141
+ }
142
+ return roots;
143
+ }
144
+
145
+ function excludedPathMatcher(values) {
146
+ const prefixes = normalizedExcludedPrefixes(values);
147
+ const root = { terminal: false, children: new Map() };
148
+ for (const prefix of prefixes) {
149
+ let current = root;
150
+ for (const segment of prefix.split('/')) {
151
+ if (!current.children.has(segment)) {
152
+ current.children.set(segment, { terminal: false, children: new Map() });
153
+ }
154
+ current = current.children.get(segment);
155
+ }
156
+ current.terminal = true;
157
+ }
158
+ return {
159
+ prefixes,
160
+ matches(relativePath) {
161
+ let current = root;
162
+ for (const segment of relativePath.split('/').filter(Boolean)) {
163
+ current = current.children.get(segment);
164
+ if (!current) return false;
165
+ if (current.terminal) return true;
166
+ }
167
+ return current.terminal;
168
+ },
169
+ };
170
+ }
171
+
172
+ function contentDigest(target, stat, options = {}) {
173
+ const hash = crypto.createHash('sha256');
174
+ const excluded = excludedPathMatcher(options.excludedPrefixes);
175
+ const visit = (candidate, relative) => {
176
+ if (relative && excluded.matches(relative)) return;
177
+ if (typeof options.onVisit === 'function') options.onVisit(candidate, relative);
178
+ const held = fs.lstatSync(candidate, { bigint: true });
179
+ const kind = held.isSymbolicLink()
180
+ ? 'symlink'
181
+ : held.isDirectory()
182
+ ? 'folder'
183
+ : held.isFile()
184
+ ? 'file'
185
+ : 'other';
186
+ hash.update(`${relative}\0${kind}\0${held.mode}\0${held.size}\0`);
187
+ if (kind === 'symlink') {
188
+ hash.update(fs.readlinkSync(candidate));
189
+ return;
190
+ }
191
+ if (kind === 'file') {
192
+ hash.update(fs.readFileSync(candidate));
193
+ return;
194
+ }
195
+ if (kind !== 'folder') return;
196
+ for (const name of fs.readdirSync(candidate).sort()) {
197
+ visit(path.join(candidate, name), relative ? `${relative}/${name}` : name);
198
+ }
199
+ };
200
+ visit(target, '');
201
+ hash.update(`\0root:${stat.dev}:${stat.ino}`);
202
+ return hash.digest('hex');
203
+ }
204
+
205
+ function identity(target, options = {}) {
206
+ const stat = fs.lstatSync(target, { bigint: true });
207
+ const excludedPrefixes = normalizedExcludedPrefixes(options.excludedPrefixes);
208
+ const value = {
209
+ kind: stat.isSymbolicLink()
210
+ ? 'symlink'
211
+ : stat.isDirectory()
212
+ ? 'folder'
213
+ : stat.isFile()
214
+ ? 'file'
215
+ : 'other',
216
+ dev: stat.dev.toString(),
217
+ ino: stat.ino.toString(),
218
+ };
219
+ if (options.deep !== false) {
220
+ value.digest = contentDigest(target, stat, {
221
+ excludedPrefixes,
222
+ onVisit: options.onVisit,
223
+ });
224
+ if (excludedPrefixes.length) value.excludedPrefixes = excludedPrefixes;
225
+ }
226
+ return value;
227
+ }
228
+
229
+ function nativeByteIdentity(target, stat = fs.lstatSync(target, { bigint: true })) {
230
+ let content;
231
+ let kind;
232
+ if (stat.isSymbolicLink()) {
233
+ kind = 'symlink';
234
+ content = Buffer.from(fs.readlinkSync(target));
235
+ } else if (stat.isFile()) {
236
+ kind = 'file';
237
+ content = fs.readFileSync(target);
238
+ } else {
239
+ throw new Error(`workspace tree retirement only supports byte leaves: ${target}`);
240
+ }
241
+ return {
242
+ kind,
243
+ exec: kind === 'file' && (stat.mode & 0o100n) !== 0n,
244
+ bytes: content.length,
245
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
246
+ ...((kind === 'file' || kind === 'symlink') ? {
247
+ dev: stat.dev.toString(),
248
+ ino: stat.ino.toString(),
249
+ } : {}),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Capture the exact leaf set before a destructive tree transition. The deep
255
+ * root identity proves nothing changed before staging; the leaf manifest lets
256
+ * cleanup retire unchanged bytes individually while retaining only a newer or
257
+ * still-open writer and the ancestors that writer needs.
258
+ */
259
+ function removalPrecondition(target, options = {}) {
260
+ const entries = [];
261
+ const directories = [];
262
+ const excluded = excludedPathMatcher(options.excludedPrefixes);
263
+ const visit = (candidate, relativePath) => {
264
+ if (relativePath && excluded.matches(relativePath)) return;
265
+ if (typeof options.onVisit === 'function') options.onVisit(candidate, relativePath);
266
+ const stat = fs.lstatSync(candidate, { bigint: true });
267
+ if (stat.isDirectory()) {
268
+ directories.push({
269
+ relativePath,
270
+ dev: stat.dev.toString(),
271
+ ino: stat.ino.toString(),
272
+ });
273
+ for (const name of fs.readdirSync(candidate).sort()) {
274
+ visit(
275
+ path.join(candidate, name),
276
+ relativePath ? `${relativePath}/${name}` : name,
277
+ );
278
+ }
279
+ return;
280
+ }
281
+ entries.push({
282
+ relativePath,
283
+ identity: nativeByteIdentity(candidate, stat),
284
+ });
285
+ };
286
+ visit(target, '');
287
+ return {
288
+ identity: identity(target, {
289
+ excludedPrefixes: excluded.prefixes,
290
+ }),
291
+ retirement: { entries, directories },
292
+ };
293
+ }
294
+
295
+ function sameIdentity(target, expected) {
296
+ try {
297
+ return JSON.stringify(identity(target, {
298
+ deep: Object.prototype.hasOwnProperty.call(expected || {}, 'digest'),
299
+ excludedPrefixes: expected?.excludedPrefixes,
300
+ })) === JSON.stringify(expected);
301
+ } catch (error) {
302
+ if (isPathAbsentError(error)) return false;
303
+ throw error;
304
+ }
305
+ }
306
+
307
+ function exists(target) {
308
+ try {
309
+ fs.lstatSync(target);
310
+ return true;
311
+ } catch (error) {
312
+ if (isPathAbsentError(error)) return false;
313
+ throw error;
314
+ }
315
+ }
316
+
317
+ function stagingRoot(plan) {
318
+ const id = crypto.createHash('sha256')
319
+ .update(
320
+ `${plan.contract || CONTRACT}\0${plan.resourceId}\0`
321
+ + `${plan.snapshotVersion ?? plan.version}\0${plan.snapshotChecksum || plan.mutationId}`,
322
+ )
323
+ .digest('hex')
324
+ .slice(0, 24);
325
+ // A receipt is not user content. Keep it beside the registered workspace so
326
+ // it remains on the same filesystem without ever entering that workspace's
327
+ // observer namespace.
328
+ return path.join(path.dirname(plan.rootPath), `.amalgm-tree-apply-${id}`);
329
+ }
330
+
331
+ function normalizeOperation(row) {
332
+ if (!row) return null;
333
+ return {
334
+ resourceId: row.resource_id,
335
+ mutationId: row.mutation_id,
336
+ version: Number(row.official_version),
337
+ plan: JSON.parse(row.plan_json),
338
+ state: row.materialization_state,
339
+ createdAt: row.created_at,
340
+ updatedAt: row.updated_at,
341
+ };
342
+ }
343
+
344
+ function getOperation(resourceId, mutationId, database = openLocalDb()) {
345
+ ensureSchema(database);
346
+ return normalizeOperation(database.prepare(`
347
+ SELECT * FROM workspace_tree_operation_materializations
348
+ WHERE resource_id = ? AND mutation_id = ?
349
+ `).get(resourceId, mutationId));
350
+ }
351
+
352
+ function listOperations(database = openLocalDb(), resourceId = null) {
353
+ ensureSchema(database);
354
+ const rows = resourceId
355
+ ? database.prepare(`
356
+ SELECT * FROM workspace_tree_operation_materializations
357
+ WHERE resource_id = ?
358
+ ORDER BY official_version ASC, created_at ASC
359
+ `).all(resourceId)
360
+ : database.prepare(`
361
+ SELECT * FROM workspace_tree_operation_materializations
362
+ ORDER BY created_at ASC, resource_id ASC, official_version ASC
363
+ `).all();
364
+ return rows.map(normalizeOperation);
365
+ }
366
+
367
+ function appendOperationSuccessor(
368
+ owner,
369
+ successorMutationId,
370
+ operation,
371
+ database = openLocalDb(),
372
+ ) {
373
+ ensureSchema(database);
374
+ // Cancellation may remove a prepared control row immediately before the
375
+ // observer discovers a structural successor. Recreate that exact owner in
376
+ // the same transaction as the first successor append: a crash may leave
377
+ // both facts or neither, never an orphan chain with no recovery plan.
378
+ if (!getOperation(owner.resourceId, owner.mutationId, database)) {
379
+ insertOperation(owner, database);
380
+ }
381
+ const operationJson = JSON.stringify(operation);
382
+ database.prepare(`
383
+ INSERT INTO workspace_tree_operation_successors (
384
+ resource_id, owner_mutation_id, successor_mutation_id, operation_json
385
+ ) VALUES (?, ?, ?, ?)
386
+ ON CONFLICT(resource_id, owner_mutation_id, successor_mutation_id)
387
+ DO UPDATE SET operation_json = excluded.operation_json
388
+ `).run(
389
+ owner.resourceId,
390
+ owner.mutationId,
391
+ successorMutationId,
392
+ operationJson,
393
+ );
394
+ }
395
+
396
+ function listOperationSuccessors(owner, database = openLocalDb()) {
397
+ ensureSchema(database);
398
+ return database.prepare(`
399
+ SELECT successor_mutation_id, operation_json
400
+ FROM workspace_tree_operation_successors
401
+ WHERE resource_id = ? AND owner_mutation_id = ?
402
+ ORDER BY successor_seq
403
+ `).all(owner.resourceId, owner.mutationId).map((row) => ({
404
+ mutationId: row.successor_mutation_id,
405
+ operation: JSON.parse(row.operation_json),
406
+ }));
407
+ }
408
+
409
+ function clearOperationSuccessors(owner, database = openLocalDb()) {
410
+ ensureSchema(database);
411
+ database.prepare(`
412
+ DELETE FROM workspace_tree_operation_successors
413
+ WHERE resource_id = ? AND owner_mutation_id = ?
414
+ `).run(owner.resourceId, owner.mutationId);
415
+ }
416
+
417
+ function insertOperation(plan, database = openLocalDb()) {
418
+ ensureSchema(database);
419
+ const body = JSON.stringify(plan);
420
+ database.prepare(`
421
+ INSERT INTO workspace_tree_operation_materializations (
422
+ resource_id, mutation_id, official_version, plan_json
423
+ ) VALUES (?, ?, ?, ?)
424
+ ON CONFLICT(resource_id, mutation_id) DO NOTHING
425
+ `).run(plan.resourceId, plan.mutationId, plan.version, body);
426
+ const held = getOperation(plan.resourceId, plan.mutationId, database);
427
+ if (!held || held.version !== plan.version || JSON.stringify(held.plan) !== body) {
428
+ throw new Error(
429
+ `workspace tree operation ${plan.resourceId}/${plan.mutationId} changed its physical plan`,
430
+ );
431
+ }
432
+ return held;
433
+ }
434
+
435
+ function replacePreparedOperation(held, plan, database = openLocalDb()) {
436
+ if (
437
+ !held
438
+ || held.state !== 'prepared'
439
+ || held.resourceId !== plan.resourceId
440
+ || held.mutationId !== plan.mutationId
441
+ ) {
442
+ throw new Error('workspace tree replacement plan has no prepared predecessor');
443
+ }
444
+ const body = JSON.stringify(plan);
445
+ const result = database.prepare(`
446
+ UPDATE workspace_tree_operation_materializations
447
+ SET official_version = ?,
448
+ plan_json = ?,
449
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
450
+ WHERE resource_id = ?
451
+ AND mutation_id = ?
452
+ AND materialization_state = 'prepared'
453
+ `).run(
454
+ plan.version,
455
+ body,
456
+ plan.resourceId,
457
+ plan.mutationId,
458
+ );
459
+ if (result.changes !== 1) {
460
+ throw new Error(
461
+ `workspace tree operation ${plan.resourceId}/${plan.mutationId} lost its prepared plan`,
462
+ );
463
+ }
464
+ const replaced = getOperation(plan.resourceId, plan.mutationId, database);
465
+ if (
466
+ !replaced
467
+ || replaced.version !== plan.version
468
+ || JSON.stringify(replaced.plan) !== body
469
+ ) {
470
+ throw new Error(
471
+ `workspace tree operation ${plan.resourceId}/${plan.mutationId} changed its replacement plan`,
472
+ );
473
+ }
474
+ return replaced;
475
+ }
476
+
477
+ /**
478
+ * A committed plan remains the durable owner of post-commit cleanup. Cleanup
479
+ * may acquire a named wait (for example, current authority forbids birthing
480
+ * retained writer bytes), so record that wait on the same immutable plan
481
+ * identity instead of creating a second control record.
482
+ */
483
+ function updateCommittedPlan(held, plan, database = openLocalDb()) {
484
+ if (!held || held.state !== 'committed' || held.resourceId !== plan.resourceId) {
485
+ throw new Error('workspace tree committed plan update has no committed predecessor');
486
+ }
487
+ const operation = held.mutationId != null;
488
+ if (
489
+ operation
490
+ ? held.mutationId !== plan.mutationId || held.version !== Number(plan.version)
491
+ : held.snapshotVersion !== Number(plan.snapshotVersion)
492
+ || held.snapshotChecksum !== plan.snapshotChecksum
493
+ ) {
494
+ throw new Error('workspace tree committed plan update changed plan identity');
495
+ }
496
+ const body = JSON.stringify(plan);
497
+ const result = operation
498
+ ? database.prepare(`
499
+ UPDATE workspace_tree_operation_materializations
500
+ SET plan_json = ?,
501
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
502
+ WHERE resource_id = ?
503
+ AND mutation_id = ?
504
+ AND materialization_state = 'committed'
505
+ `).run(body, held.resourceId, held.mutationId)
506
+ : database.prepare(`
507
+ UPDATE workspace_tree_materializations
508
+ SET plan_json = ?,
509
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
510
+ WHERE resource_id = ?
511
+ AND snapshot_version = ?
512
+ AND snapshot_checksum = ?
513
+ AND materialization_state = 'committed'
514
+ `).run(
515
+ body,
516
+ held.resourceId,
517
+ held.snapshotVersion,
518
+ held.snapshotChecksum,
519
+ );
520
+ if (result.changes !== 1) {
521
+ throw new Error('workspace tree committed plan lost its durable owner');
522
+ }
523
+ const updated = operation
524
+ ? getOperation(held.resourceId, held.mutationId, database)
525
+ : get(held.resourceId, held.snapshotVersion, database);
526
+ if (!updated || updated.state !== 'committed' || JSON.stringify(updated.plan) !== body) {
527
+ throw new Error('workspace tree committed plan changed during durable update');
528
+ }
529
+ return updated;
530
+ }
531
+
532
+ function updateUncommittedPlan(held, plan, database = openLocalDb()) {
533
+ if (
534
+ !held
535
+ || held.state === 'committed'
536
+ || held.resourceId !== plan.resourceId
537
+ || held.snapshotVersion !== Number(plan.snapshotVersion)
538
+ || held.snapshotChecksum !== plan.snapshotChecksum
539
+ ) {
540
+ throw new Error('workspace tree uncommitted plan update has no exact predecessor');
541
+ }
542
+ const body = JSON.stringify(plan);
543
+ const result = database.prepare(`
544
+ UPDATE workspace_tree_materializations
545
+ SET plan_json = ?,
546
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
547
+ WHERE resource_id = ?
548
+ AND snapshot_version = ?
549
+ AND snapshot_checksum = ?
550
+ AND materialization_state = ?
551
+ `).run(
552
+ body,
553
+ held.resourceId,
554
+ held.snapshotVersion,
555
+ held.snapshotChecksum,
556
+ held.state,
557
+ );
558
+ if (result.changes !== 1) {
559
+ throw new Error('workspace tree uncommitted plan lost its durable owner');
560
+ }
561
+ const updated = get(held.resourceId, held.snapshotVersion, database);
562
+ if (
563
+ !updated
564
+ || updated.state !== held.state
565
+ || JSON.stringify(updated.plan) !== body
566
+ ) {
567
+ throw new Error('workspace tree uncommitted plan changed during durable update');
568
+ }
569
+ return updated;
570
+ }
571
+
572
+ function ensureDurableDirectory(directory) {
573
+ const target = path.resolve(directory);
574
+ const missing = [];
575
+ let current = target;
576
+ for (;;) {
577
+ try {
578
+ const stat = fs.lstatSync(current);
579
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
580
+ throw new Error(`workspace tree parent is not a physical directory: ${current}`);
581
+ }
582
+ break;
583
+ } catch (error) {
584
+ if (!isPathAbsentError(error)) throw error;
585
+ missing.push(current);
586
+ const parent = path.dirname(current);
587
+ if (parent === current) {
588
+ throw new Error(`workspace tree directory has no existing ancestor: ${target}`);
589
+ }
590
+ current = parent;
591
+ }
592
+ }
593
+ for (const candidate of missing.reverse()) {
594
+ try {
595
+ fs.mkdirSync(candidate);
596
+ } catch (error) {
597
+ if (error?.code !== 'EEXIST') throw error;
598
+ const stat = fs.lstatSync(candidate);
599
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw error;
600
+ }
601
+ // The new name is not durable until its parent entry is synced. Sync the
602
+ // new directory too before another child can depend on it.
603
+ syncDirectory(path.dirname(candidate));
604
+ syncDirectory(candidate);
605
+ }
606
+ syncDirectory(target);
607
+ return identity(target, { deep: false });
608
+ }
609
+
610
+ function ensureOperationDirectory(directory) {
611
+ return ensureDurableDirectory(directory);
612
+ }
613
+
614
+ function createOperationTargetAt(target, action) {
615
+ if (exists(target)) {
616
+ throw new Error(`workspace tree operation prepared target already exists for ${action.nodeId}`);
617
+ }
618
+ ensureOperationDirectory(path.dirname(target));
619
+ if (action.kind === 'folder') ensureOperationDirectory(target);
620
+ else if (action.kind === 'file') {
621
+ const descriptor = fs.openSync(target, 'wx');
622
+ try {
623
+ fs.fsyncSync(descriptor);
624
+ } finally {
625
+ fs.closeSync(descriptor);
626
+ }
627
+ syncDirectory(path.dirname(target));
628
+ } else if (action.kind === 'symlink') {
629
+ fs.symlinkSync(action.symlinkTarget, target);
630
+ syncDirectory(path.dirname(target));
631
+ } else {
632
+ throw new Error(`unsupported workspace tree operation kind ${action.kind}`);
633
+ }
634
+ }
635
+
636
+ function receiptPath(plan, lane, nodeId) {
637
+ return path.join(stagingRoot(plan), 'receipts', `${lane}-${nodeId}.json`);
638
+ }
639
+
640
+ function readReceipt(plan, lane, nodeId) {
641
+ const file = receiptPath(plan, lane, nodeId);
642
+ if (!exists(file)) return null;
643
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
644
+ }
645
+
646
+ function writeReceipt(plan, lane, nodeId, value) {
647
+ const file = receiptPath(plan, lane, nodeId);
648
+ const body = JSON.stringify(value);
649
+ const existing = readReceipt(plan, lane, nodeId);
650
+ if (existing) {
651
+ if (JSON.stringify(existing) !== body) {
652
+ throw new Error(`workspace tree receipt changed for ${nodeId}`);
653
+ }
654
+ return existing;
655
+ }
656
+ ensureDirectory(plan, path.dirname(file));
657
+ const descriptor = fs.openSync(file, 'wx');
658
+ try {
659
+ fs.writeFileSync(descriptor, body);
660
+ fs.fsyncSync(descriptor);
661
+ } finally {
662
+ fs.closeSync(descriptor);
663
+ }
664
+ syncDirectory(path.dirname(file));
665
+ return value;
666
+ }
667
+
668
+ function ownedIdentity(target, expected) {
669
+ if (!expected) return false;
670
+ try {
671
+ const observed = identity(target, { deep: false });
672
+ return observed.kind === expected.kind
673
+ && observed.dev === expected.dev
674
+ && observed.ino === expected.ino;
675
+ } catch (error) {
676
+ if (isPathAbsentError(error)) return false;
677
+ throw error;
678
+ }
679
+ }
680
+
681
+ function plannedParentIdentity(plan, nodeId, phase) {
682
+ if (!nodeId || nodeId === plan.resourceId) return plan.rootIdentity;
683
+ if (phase === 'target') {
684
+ const created = readReceipt(plan, 'creates', nodeId);
685
+ if (created) return created;
686
+ const projected = (plan.projectedBindings || [])
687
+ .find((binding) => binding.nodeId === nodeId);
688
+ if (projected) return projected.identity;
689
+ }
690
+ const rollback = (plan.rollbackBindings || plan.projectedBindings || [])
691
+ .find((binding) => binding.nodeId === nodeId);
692
+ if (rollback) return rollback.identity;
693
+ throw new Error(
694
+ `workspace tree ${phase} parent ${nodeId} has no physical binding`,
695
+ );
696
+ }
697
+
698
+ function requireOwnedDirectory(directory, expected, label) {
699
+ if (!expected || expected.kind !== 'folder' || !ownedIdentity(directory, expected)) {
700
+ throw parentRace(`workspace tree ${label} parent changed: ${directory}`);
701
+ }
702
+ syncDirectory(directory);
703
+ }
704
+
705
+ function sourceRace(message) {
706
+ return Object.assign(new Error(message), {
707
+ code: 'WORKSPACE_TREE_SOURCE_RACE',
708
+ });
709
+ }
710
+
711
+ function parentRace(message) {
712
+ return Object.assign(new Error(message), {
713
+ code: 'WORKSPACE_TREE_PARENT_RACE',
714
+ });
715
+ }
716
+
717
+ function operationRecoveryContext(plan, options = {}) {
718
+ const bindings = new Map(
719
+ (plan.rollbackBindings || []).map((binding) => [binding.nodeId, binding]),
720
+ );
721
+ const actions = new Map(
722
+ (plan.actions || []).map((action) => [action.nodeId, action]),
723
+ );
724
+ const operationIdentities = Array.from(actions.keys()).map((nodeId) => (
725
+ readReceipt(plan, 'create', nodeId)
726
+ || readReceipt(plan, 'move', nodeId)
727
+ || readReceipt(plan, 'remove', nodeId)
728
+ )).filter(Boolean);
729
+ let indexed = indexProjectedBindingLocations(plan, {
730
+ identities: operationIdentities,
731
+ });
732
+ let refreshed = false;
733
+ const indexedLocation = (expected) => {
734
+ const key = receiptIdentityKey(expected);
735
+ const candidates = indexed.get(key) || [];
736
+ const current = candidates.find((candidate) => ownedIdentity(candidate, expected));
737
+ if (current) return current;
738
+ if (!refreshed) {
739
+ indexed = indexProjectedBindingLocations(plan, {
740
+ identities: operationIdentities,
741
+ });
742
+ refreshed = true;
743
+ return (indexed.get(key) || [])
744
+ .find((candidate) => ownedIdentity(candidate, expected)) || null;
745
+ }
746
+ if (candidates.length && refreshed) {
747
+ throw sourceRace('workspace tree operation recovery index changed twice');
748
+ }
749
+ return null;
750
+ };
751
+ const nodeLocation = (nodeId) => {
752
+ if (!nodeId || nodeId === plan.resourceId) return plan.rootPath;
753
+ const action = actions.get(nodeId);
754
+ const binding = bindings.get(nodeId);
755
+ const expected = (
756
+ readReceipt(plan, 'create', nodeId)
757
+ || readReceipt(plan, 'move', nodeId)
758
+ || binding?.identity
759
+ );
760
+ if (!expected) return null;
761
+ const exact = [
762
+ action?.target,
763
+ action?.source,
764
+ action ? stagedPath(plan, 'moves', nodeId) : null,
765
+ action ? stagedPath(plan, 'creates', nodeId) : null,
766
+ binding?.from,
767
+ ].filter(Boolean).find((candidate) => ownedIdentity(candidate, expected));
768
+ return exact || indexedLocation(expected);
769
+ };
770
+ const parent = (action, phase) => {
771
+ const nodeId = action[`${phase}ParentNodeId`];
772
+ if (nodeId === plan.resourceId) {
773
+ return { path: plan.rootPath, identity: plan.rootIdentity };
774
+ }
775
+ if (nodeId) {
776
+ const location = nodeLocation(nodeId);
777
+ if (!location) return null;
778
+ if (options.allowRelocated !== true) {
779
+ const ownerAction = actions.get(nodeId);
780
+ const binding = bindings.get(nodeId);
781
+ const permitted = [
782
+ binding?.from,
783
+ (
784
+ ownerAction
785
+ && (
786
+ readReceipt(plan, 'move-installing', nodeId)
787
+ || readReceipt(plan, 'create-installing', nodeId)
788
+ )
789
+ ) ? ownerAction.target : null,
790
+ ownerAction ? stagedPath(plan, 'moves', nodeId) : null,
791
+ ownerAction ? stagedPath(plan, 'creates', nodeId) : null,
792
+ ].filter(Boolean);
793
+ if (!permitted.some((candidate) => ownedIdentity(candidate, identity(
794
+ location,
795
+ { deep: false },
796
+ )))) {
797
+ return null;
798
+ }
799
+ }
800
+ return {
801
+ path: location,
802
+ identity: identity(location, { deep: false }),
803
+ };
804
+ }
805
+ const spelled = path.dirname(action[phase]);
806
+ const expected = action[`${phase}ParentIdentity`];
807
+ if (!expected || !ownedIdentity(spelled, expected)) return null;
808
+ return { path: spelled, identity: expected };
809
+ };
810
+ return {
811
+ nodeLocation,
812
+ resolve(action, phase) {
813
+ const held = parent(action, phase);
814
+ if (!held) return null;
815
+ return {
816
+ path: path.join(held.path, path.basename(action[phase])),
817
+ parentIdentity: held.identity,
818
+ };
819
+ },
820
+ };
821
+ }
822
+
823
+ function retainPrivatePath(plan, staged, preferred, token, options = {}) {
824
+ const provenance = options.provenance || 'independent-occupant';
825
+ const existingOutputs = options.item && options.lane
826
+ ? (plan.retainedOutputs || []).filter((output) => (
827
+ output.removalNodeId === options.item.nodeId
828
+ && output.lane === options.lane
829
+ && output.provenance === provenance
830
+ ))
831
+ : [];
832
+ if (!exists(staged)) {
833
+ const retained = existingOutputs.filter((output) => (
834
+ ownedIdentity(output.path, output.identity)
835
+ ));
836
+ return retained.length === 1 ? retained[0].path : null;
837
+ }
838
+ const expected = identity(staged, { deep: false });
839
+ let target = preferred;
840
+ let targetParentIdentity = null;
841
+ if (target && exists(path.dirname(target))) {
842
+ const parent = identity(path.dirname(target), { deep: false });
843
+ if (parent.kind === 'folder') targetParentIdentity = parent;
844
+ }
845
+ if (!targetParentIdentity) {
846
+ target = path.join(
847
+ plan.rootPath,
848
+ `.amalgm-retained-${token}-${path.basename(preferred || staged)}`,
849
+ );
850
+ targetParentIdentity = plan.rootIdentity;
851
+ }
852
+ if (exists(target)) target = retainedConflictPath(target, token);
853
+ if (options.item && options.lane) {
854
+ const relativePath = provenance === 'carried-source'
855
+ ? ''
856
+ : `entry-${expected.kind}-${expected.dev}-${expected.ino}`;
857
+ const role = provenance === 'carried-source' ? 'carried' : 'occupant';
858
+ const outputId = retainedOutputId(
859
+ plan,
860
+ options.item,
861
+ options.lane,
862
+ relativePath,
863
+ role,
864
+ );
865
+ const existing = existingOutputs.find(
866
+ (output) => output.outputId === outputId,
867
+ );
868
+ if (existing) target = existing.path;
869
+ const retained = mergeRetained(
870
+ plan,
871
+ options.item,
872
+ staged,
873
+ target,
874
+ token,
875
+ {
876
+ ...(options.outputOptions || {}),
877
+ lane: options.lane,
878
+ provenance,
879
+ exclusive: true,
880
+ targetParentIdentity,
881
+ },
882
+ relativePath,
883
+ );
884
+ return retained.find((output) => output.outputId === outputId)?.path || target;
885
+ }
886
+ exclusiveRename(
887
+ plan,
888
+ staged,
889
+ target,
890
+ `retain-private-${token}`,
891
+ expected,
892
+ { targetParentIdentity },
893
+ );
894
+ return target;
895
+ }
896
+
897
+ function retainUnexpectedStage(
898
+ plan,
899
+ source,
900
+ staged,
901
+ nodeId,
902
+ sourceParentIdentity,
903
+ ) {
904
+ if (!exists(staged)) return null;
905
+ const unexpected = identity(staged, { deep: false });
906
+ const token = crypto.createHash('sha256')
907
+ .update(
908
+ `${plan.snapshotChecksum || plan.mutationId}\0source-race\0${nodeId}`,
909
+ )
910
+ .digest('hex')
911
+ .slice(0, 12);
912
+ const target = exists(source)
913
+ ? retainedConflictPath(source, token)
914
+ : source;
915
+ const result = exclusiveRename(
916
+ plan,
917
+ staged,
918
+ target,
919
+ `retain-raced-stage-${nodeId}`,
920
+ unexpected,
921
+ {
922
+ targetParentIdentity: sourceParentIdentity,
923
+ allowChanged: true,
924
+ },
925
+ );
926
+ if (result.status !== 'applied' && exists(staged)) {
927
+ throw new Error(`workspace tree raced stage could not be retained for ${nodeId}`);
928
+ }
929
+ return target;
930
+ }
931
+
932
+ function stageOwnedPath({
933
+ plan,
934
+ source,
935
+ staged,
936
+ nodeId,
937
+ receiptLane,
938
+ expected,
939
+ sourceParentIdentity,
940
+ operation,
941
+ renameTestHooks = {},
942
+ }) {
943
+ let receipt = readReceipt(plan, receiptLane, nodeId);
944
+ if (!receipt) {
945
+ receipt = writeReceipt(plan, receiptLane, nodeId, {
946
+ kind: expected.kind,
947
+ dev: expected.dev,
948
+ ino: expected.ino,
949
+ });
950
+ }
951
+ if (ownedIdentity(staged, receipt)) return receipt;
952
+ if (exists(staged)) {
953
+ retainUnexpectedStage(
954
+ plan,
955
+ source,
956
+ staged,
957
+ nodeId,
958
+ sourceParentIdentity,
959
+ );
960
+ throw sourceRace(`workspace tree source raced staging for ${nodeId}`);
961
+ }
962
+ if (!ownedIdentity(source, receipt)) {
963
+ throw sourceRace(`workspace tree source changed before staging for ${nodeId}`);
964
+ }
965
+ ensureDirectory(plan, path.dirname(staged));
966
+ const result = exclusiveRename(
967
+ plan,
968
+ source,
969
+ staged,
970
+ operation,
971
+ receipt,
972
+ {
973
+ sourceParentIdentity,
974
+ allowChanged: true,
975
+ testHooks: renameTestHooks,
976
+ },
977
+ );
978
+ if (ownedIdentity(staged, receipt)) return receipt;
979
+ if (exists(staged)) {
980
+ retainUnexpectedStage(
981
+ plan,
982
+ source,
983
+ staged,
984
+ nodeId,
985
+ sourceParentIdentity,
986
+ );
987
+ }
988
+ throw sourceRace(
989
+ `workspace tree source could not be staged for ${nodeId}: `
990
+ + `${result.reason || 'source changed'}`,
991
+ );
992
+ }
993
+
994
+ function crossCreateAction(plan, action, context, testHooks = {}) {
995
+ const prepared = stagedPath(plan, 'creates', action.nodeId);
996
+ let receipt = readReceipt(plan, 'create', action.nodeId);
997
+ if (!receipt) {
998
+ if (!exists(prepared)) createOperationTargetAt(prepared, action);
999
+ receipt = writeReceipt(
1000
+ plan,
1001
+ 'create',
1002
+ action.nodeId,
1003
+ identity(prepared, { deep: false }),
1004
+ );
1005
+ }
1006
+ if (exists(prepared)) {
1007
+ if (!ownedIdentity(prepared, receipt)) {
1008
+ throw new Error(`workspace tree prepared create changed for ${plan.mutationId}`);
1009
+ }
1010
+ if (typeof testHooks.afterOperationCreatePrepared === 'function') {
1011
+ testHooks.afterOperationCreatePrepared(action, prepared);
1012
+ }
1013
+ requireOwnedDirectory(
1014
+ path.dirname(action.target),
1015
+ action.targetParentIdentity,
1016
+ 'operation create',
1017
+ );
1018
+ writeReceipt(plan, 'create-installing', action.nodeId, receipt);
1019
+ if (!installOwnedPath(
1020
+ plan,
1021
+ prepared,
1022
+ action.target,
1023
+ receipt,
1024
+ `operation-create-install-${action.nodeId}`,
1025
+ {
1026
+ operationParent: true,
1027
+ targetParentIdentity: action.targetParentIdentity,
1028
+ },
1029
+ )) {
1030
+ throw new Error(`workspace tree create lost its receipt for ${plan.mutationId}`);
1031
+ }
1032
+ return;
1033
+ }
1034
+ const installing = readReceipt(
1035
+ plan,
1036
+ 'create-installing',
1037
+ action.nodeId,
1038
+ );
1039
+ if (installing && ownedIdentity(action.target, receipt)) return;
1040
+ const installedLocation = installing
1041
+ ? context.nodeLocation(action.nodeId)
1042
+ : null;
1043
+ if (
1044
+ installedLocation
1045
+ && !pathInside(installedLocation, stagingRoot(plan))
1046
+ && ownedIdentity(installedLocation, receipt)
1047
+ ) {
1048
+ throw sourceRace(
1049
+ `workspace tree installed create moved before recovery for ${plan.mutationId}`,
1050
+ );
1051
+ }
1052
+ if (installing && !installedLocation) {
1053
+ throw sourceRace(
1054
+ `workspace tree installed create disappeared before recovery for ${plan.mutationId}`,
1055
+ );
1056
+ }
1057
+ if (!installing || !ownedIdentity(action.target, receipt)) {
1058
+ throw new Error(`workspace tree create has no crossing receipt for ${plan.mutationId}`);
1059
+ }
1060
+ }
1061
+
1062
+ function crossMoveAction(plan, action, testHooks = {}) {
1063
+ const staged = stagedPath(plan, 'moves', action.nodeId);
1064
+ const installedReceipt = readReceipt(plan, 'move-installed', action.nodeId);
1065
+ if (installedReceipt && ownedIdentity(action.target, installedReceipt)) return;
1066
+ const movingReceipt = readReceipt(plan, 'move', action.nodeId);
1067
+ const installingReceipt = readReceipt(plan, 'move-installing', action.nodeId);
1068
+ if (
1069
+ movingReceipt
1070
+ && installingReceipt
1071
+ && !exists(staged)
1072
+ && ownedIdentity(action.target, movingReceipt)
1073
+ ) {
1074
+ writeReceipt(plan, 'move-installed', action.nodeId, movingReceipt);
1075
+ return;
1076
+ }
1077
+ const receipt = stageOwnedPath({
1078
+ plan,
1079
+ source: action.source,
1080
+ staged,
1081
+ nodeId: action.nodeId,
1082
+ receiptLane: 'move',
1083
+ expected: action.identity,
1084
+ sourceParentIdentity: action.sourceParentIdentity,
1085
+ operation: `operation-move-stage-${action.nodeId}`,
1086
+ renameTestHooks: testHooks.operationStageRename || {},
1087
+ });
1088
+ if (exists(staged)) {
1089
+ if (!ownedIdentity(staged, receipt)) {
1090
+ throw new Error(`workspace tree staged move lost ownership for ${plan.mutationId}`);
1091
+ }
1092
+ requireOwnedDirectory(
1093
+ path.dirname(action.target),
1094
+ action.targetParentIdentity,
1095
+ 'operation move',
1096
+ );
1097
+ writeReceipt(plan, 'move-installing', action.nodeId, receipt);
1098
+ if (!installOwnedPath(
1099
+ plan,
1100
+ staged,
1101
+ action.target,
1102
+ receipt,
1103
+ `operation-move-install-${action.nodeId}`,
1104
+ {
1105
+ operationParent: true,
1106
+ targetParentIdentity: action.targetParentIdentity,
1107
+ },
1108
+ )) {
1109
+ throw new Error(`workspace tree move lost its receipt for ${plan.mutationId}`);
1110
+ }
1111
+ if (typeof testHooks.afterOperationMoveInstall === 'function') {
1112
+ testHooks.afterOperationMoveInstall(action);
1113
+ }
1114
+ writeReceipt(plan, 'move-installed', action.nodeId, receipt);
1115
+ return;
1116
+ }
1117
+ if (ownedIdentity(action.target, receipt)) {
1118
+ throw sourceRace(
1119
+ `workspace tree move target has no operation crossing receipt for ${plan.mutationId}`,
1120
+ );
1121
+ }
1122
+ throw new Error(`workspace tree move has no crossing receipt for ${plan.mutationId}`);
1123
+ }
1124
+
1125
+ function crossRemoveAction(plan, action, testHooks = {}) {
1126
+ const staged = stagedPath(plan, 'removed', action.nodeId);
1127
+ const receipt = stageOwnedPath({
1128
+ plan,
1129
+ source: action.source,
1130
+ staged,
1131
+ nodeId: action.nodeId,
1132
+ receiptLane: 'remove',
1133
+ expected: action.identity,
1134
+ sourceParentIdentity: action.sourceParentIdentity,
1135
+ operation: `operation-remove-stage-${action.nodeId}`,
1136
+ renameTestHooks: testHooks.operationStageRename || {},
1137
+ });
1138
+ if (!ownedIdentity(staged, receipt)) {
1139
+ throw new Error(`workspace tree removal has no crossing receipt for ${plan.mutationId}`);
1140
+ }
1141
+ }
1142
+
1143
+ function rollbackOperationCreate(
1144
+ plan,
1145
+ action,
1146
+ context,
1147
+ testHooks = {},
1148
+ outputOptions = {},
1149
+ ) {
1150
+ const receipt = readReceipt(plan, 'create', action.nodeId);
1151
+ if (!receipt) return;
1152
+ const prepared = stagedPath(plan, 'creates', action.nodeId);
1153
+ const cancelId = `cancel-operation-create-${action.nodeId}`;
1154
+ const cancelled = stagedPath(plan, 'removed', cancelId);
1155
+ if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
1156
+ retainPrivatePath(
1157
+ plan,
1158
+ cancelled,
1159
+ action.target,
1160
+ `cancel-create-unexpected-${action.nodeId}`,
1161
+ {
1162
+ item: { nodeId: `cancel-create-unexpected-${action.nodeId}` },
1163
+ lane: 'cancel-create-unexpected',
1164
+ outputOptions,
1165
+ },
1166
+ );
1167
+ }
1168
+ if (exists(prepared) && !ownedIdentity(prepared, receipt)) {
1169
+ retainPrivatePath(
1170
+ plan,
1171
+ prepared,
1172
+ action.target,
1173
+ `cancel-create-prepared-${action.nodeId}`,
1174
+ {
1175
+ item: { nodeId: `cancel-create-prepared-${action.nodeId}` },
1176
+ lane: 'cancel-create-prepared',
1177
+ outputOptions,
1178
+ },
1179
+ );
1180
+ }
1181
+ if (ownedIdentity(prepared, receipt) && !exists(cancelled)) {
1182
+ ensureDirectory(plan, path.dirname(cancelled));
1183
+ writeReceipt(plan, 'create-cancelling', action.nodeId, receipt);
1184
+ exclusiveRename(
1185
+ plan,
1186
+ prepared,
1187
+ cancelled,
1188
+ `cancel-operation-create-prepared-${action.nodeId}`,
1189
+ receipt,
1190
+ );
1191
+ }
1192
+ const resolved = context.resolve(action, 'target');
1193
+ if (
1194
+ !ownedIdentity(cancelled, receipt)
1195
+ && readReceipt(plan, 'create-installing', action.nodeId)
1196
+ && resolved
1197
+ && ownedIdentity(resolved.path, receipt)
1198
+ ) {
1199
+ ensureDirectory(plan, path.dirname(cancelled));
1200
+ writeReceipt(plan, 'create-cancelling', action.nodeId, receipt);
1201
+ exclusiveRename(
1202
+ plan,
1203
+ resolved.path,
1204
+ cancelled,
1205
+ `cancel-operation-create-installed-${action.nodeId}`,
1206
+ receipt,
1207
+ { sourceParentIdentity: resolved.parentIdentity },
1208
+ );
1209
+ }
1210
+ if (
1211
+ ownedIdentity(cancelled, receipt)
1212
+ && typeof testHooks.afterOperationCreateCancellationStage === 'function'
1213
+ ) {
1214
+ testHooks.afterOperationCreateCancellationStage(action, cancelled);
1215
+ }
1216
+ if (!ownedIdentity(cancelled, receipt)) return;
1217
+ const retainedTarget = resolved?.path || action.target;
1218
+ return retireRemoval(plan, {
1219
+ nodeId: cancelId,
1220
+ ownerNodeId: action.nodeId,
1221
+ source: retainedTarget,
1222
+ retainTarget: retainedTarget,
1223
+ retirement: initialCreateRetirement(action, receipt),
1224
+ }, {
1225
+ ...outputOptions,
1226
+ preparePlan(nextPlan, outputs) {
1227
+ if (typeof outputOptions.preparePlan === 'function') {
1228
+ outputOptions.preparePlan(nextPlan, outputs);
1229
+ }
1230
+ const carriedRoot = outputs.find((output) => (
1231
+ output.provenance === 'carried-source'
1232
+ && output.sourceRelativePath === ''
1233
+ ));
1234
+ if (carriedRoot) {
1235
+ const bindings = Array.isArray(nextPlan.carriedCreates)
1236
+ ? [...nextPlan.carriedCreates]
1237
+ : [];
1238
+ const replacement = {
1239
+ nodeId: action.nodeId,
1240
+ path: carriedRoot.path,
1241
+ identity: carriedRoot.identity,
1242
+ };
1243
+ const index = bindings.findIndex(
1244
+ (binding) => binding.nodeId === action.nodeId,
1245
+ );
1246
+ if (index === -1) bindings.push(replacement);
1247
+ else bindings[index] = replacement;
1248
+ bindings.sort((left, right) => left.nodeId.localeCompare(right.nodeId));
1249
+ nextPlan.carriedCreates = bindings;
1250
+ if (Array.isArray(nextPlan.carriedCreateDeletions)) {
1251
+ nextPlan.carriedCreateDeletions = (
1252
+ nextPlan.carriedCreateDeletions
1253
+ .filter((binding) => binding.nodeId !== action.nodeId)
1254
+ );
1255
+ }
1256
+ return;
1257
+ }
1258
+ if (!outputs.some((output) => (
1259
+ output.provenance === 'independent-occupant'
1260
+ && path.resolve(output.path) === path.resolve(retainedTarget)
1261
+ ))) return;
1262
+ const deletions = Array.isArray(nextPlan.carriedCreateDeletions)
1263
+ ? [...nextPlan.carriedCreateDeletions]
1264
+ : [];
1265
+ if (!deletions.some((binding) => binding.nodeId === action.nodeId)) {
1266
+ deletions.push({
1267
+ nodeId: action.nodeId,
1268
+ identity: receipt,
1269
+ });
1270
+ deletions.sort((left, right) => left.nodeId.localeCompare(right.nodeId));
1271
+ nextPlan.carriedCreateDeletions = deletions;
1272
+ }
1273
+ },
1274
+ });
1275
+ }
1276
+
1277
+ function cancelPreparedOperation(
1278
+ held,
1279
+ database = openLocalDb(),
1280
+ testHooks = {},
1281
+ ) {
1282
+ let current = getOperation(held.resourceId, held.mutationId, database);
1283
+ if (!current) return true;
1284
+ if (current.state !== 'prepared') return false;
1285
+ let plan = current.plan;
1286
+ const outputOptions = {
1287
+ durable: true,
1288
+ persistPlan(nextPlan) {
1289
+ current = replacePreparedOperation(current, nextPlan, database);
1290
+ plan = current.plan;
1291
+ },
1292
+ afterOutputReceipt: testHooks.afterOperationCancellationOutputReceipt,
1293
+ afterOutputMove: testHooks.afterOperationCancellationOutputMove,
1294
+ };
1295
+ let context = operationRecoveryContext(plan, { allowRelocated: true });
1296
+ const targetActions = plan.actions
1297
+ .filter((action) => action.target)
1298
+ .sort((left, right) => (
1299
+ right.target.split(path.sep).length - left.target.split(path.sep).length
1300
+ ));
1301
+ const carriedCreates = Array.isArray(plan.carriedCreates)
1302
+ ? [...plan.carriedCreates]
1303
+ : [];
1304
+ const carriedCreateDeletions = Array.isArray(plan.carriedCreateDeletions)
1305
+ ? [...plan.carriedCreateDeletions]
1306
+ : [];
1307
+ const carriedCreateIds = new Set(
1308
+ [...carriedCreates, ...carriedCreateDeletions]
1309
+ .map((binding) => binding.nodeId),
1310
+ );
1311
+ for (const action of targetActions) {
1312
+ if (action.type !== 'create' || carriedCreateIds.has(action.nodeId)) continue;
1313
+ const receipt = readReceipt(plan, 'create', action.nodeId);
1314
+ if (!receipt || !readReceipt(plan, 'create-installing', action.nodeId)) continue;
1315
+ const location = context.nodeLocation(action.nodeId);
1316
+ if (!location && !exists(stagedPath(plan, 'creates', action.nodeId))) {
1317
+ const resolvedTarget = context.resolve(action, 'target')?.path
1318
+ || action.target;
1319
+ const replacementIdentity = exists(resolvedTarget)
1320
+ ? identity(resolvedTarget, { deep: false })
1321
+ : null;
1322
+ if (
1323
+ action.kind === 'file'
1324
+ && replacementIdentity?.kind === 'file'
1325
+ ) {
1326
+ // A regular file keeps its document UUID across atomic same-path
1327
+ // replacement. The new inode is later content, not a new tree node.
1328
+ carriedCreates.push({
1329
+ nodeId: action.nodeId,
1330
+ path: resolvedTarget,
1331
+ identity: replacementIdentity,
1332
+ refreshAtSamePath: true,
1333
+ });
1334
+ } else {
1335
+ // A missing original receipt proves the installed value was removed.
1336
+ // Any different folder, symlink, or kind at its name is independent
1337
+ // and will receive a fresh UUID after this one becomes local trash.
1338
+ carriedCreateDeletions.push({
1339
+ nodeId: action.nodeId,
1340
+ identity: receipt,
1341
+ });
1342
+ }
1343
+ carriedCreateIds.add(action.nodeId);
1344
+ continue;
1345
+ }
1346
+ if (
1347
+ !location
1348
+ || pathInside(location, stagingRoot(plan))
1349
+ || !ownedIdentity(location, receipt)
1350
+ ) {
1351
+ continue;
1352
+ }
1353
+ carriedCreates.push({
1354
+ nodeId: action.nodeId,
1355
+ path: location,
1356
+ identity: receipt,
1357
+ });
1358
+ carriedCreateIds.add(action.nodeId);
1359
+ }
1360
+ if (carriedCreates.length || carriedCreateDeletions.length) {
1361
+ // Once a plan-created inode becomes visible, its receipt is UUID
1362
+ // provenance. Preserve a surviving binding or its physical disappearance
1363
+ // in the control row before cleaning private staging so a parent/child
1364
+ // race can replan through the same UUID instead of birthing a relocation
1365
+ // or resurrecting a local delete.
1366
+ plan.carriedCreates = carriedCreates;
1367
+ plan.carriedCreateDeletions = carriedCreateDeletions;
1368
+ replacePreparedOperation(current, plan, database);
1369
+ if (typeof testHooks.afterOperationCarriedCreateCapture === 'function') {
1370
+ testHooks.afterOperationCarriedCreateCapture(carriedCreates);
1371
+ }
1372
+ if (
1373
+ carriedCreateDeletions.length
1374
+ && typeof testHooks.afterOperationCarriedCreateDeletionCapture === 'function'
1375
+ ) {
1376
+ testHooks.afterOperationCarriedCreateDeletionCapture(
1377
+ carriedCreateDeletions,
1378
+ );
1379
+ }
1380
+ }
1381
+ // First pull every installed operation-owned value back into private
1382
+ // staging. The durable *-installing receipt is the only authority for
1383
+ // interpreting an exact target inode as output of this plan.
1384
+ for (const action of targetActions) {
1385
+ if (action.type !== 'move') continue;
1386
+ const receipt = readReceipt(plan, 'move', action.nodeId);
1387
+ const installing = readReceipt(plan, 'move-installing', action.nodeId);
1388
+ const staged = stagedPath(plan, 'moves', action.nodeId);
1389
+ const resolved = context.resolve(action, 'target');
1390
+ if (
1391
+ receipt
1392
+ && installing
1393
+ && resolved
1394
+ && !ownedIdentity(staged, receipt)
1395
+ && ownedIdentity(resolved.path, receipt)
1396
+ ) {
1397
+ ensureDirectory(plan, path.dirname(staged));
1398
+ exclusiveRename(
1399
+ plan,
1400
+ resolved.path,
1401
+ staged,
1402
+ `cancel-operation-extract-${action.nodeId}`,
1403
+ receipt,
1404
+ { sourceParentIdentity: resolved.parentIdentity },
1405
+ );
1406
+ }
1407
+ }
1408
+ context = operationRecoveryContext(plan, { allowRelocated: true });
1409
+ const sourceActions = plan.actions
1410
+ .filter((action) => action.type === 'move' || action.type === 'remove')
1411
+ .sort((left, right) => (
1412
+ left.source.split(path.sep).length - right.source.split(path.sep).length
1413
+ ));
1414
+ for (const action of sourceActions) {
1415
+ const lane = action.type === 'move' ? 'move' : 'remove';
1416
+ const stagedLane = action.type === 'move' ? 'moves' : 'removed';
1417
+ const receipt = readReceipt(plan, lane, action.nodeId);
1418
+ if (!receipt) continue;
1419
+ const staged = stagedPath(plan, stagedLane, action.nodeId);
1420
+ if (exists(staged) && !ownedIdentity(staged, receipt)) {
1421
+ retainPrivatePath(
1422
+ plan,
1423
+ staged,
1424
+ action.source,
1425
+ `cancel-unexpected-${action.nodeId}`,
1426
+ {
1427
+ item: { nodeId: `cancel-unexpected-${action.nodeId}` },
1428
+ lane: `cancel-unexpected-${lane}`,
1429
+ outputOptions,
1430
+ },
1431
+ );
1432
+ continue;
1433
+ }
1434
+ if (!ownedIdentity(staged, receipt)) continue;
1435
+ const resolved = context.resolve(action, 'source');
1436
+ if (!resolved) {
1437
+ retainPrivatePath(
1438
+ plan,
1439
+ staged,
1440
+ action.source,
1441
+ `cancel-missing-parent-${action.nodeId}`,
1442
+ {
1443
+ item: { nodeId: action.nodeId },
1444
+ lane: `cancel-missing-parent-${lane}`,
1445
+ provenance: 'carried-source',
1446
+ outputOptions,
1447
+ },
1448
+ );
1449
+ continue;
1450
+ }
1451
+ installOwnedPath(
1452
+ plan,
1453
+ staged,
1454
+ resolved.path,
1455
+ receipt,
1456
+ `cancel-operation-${action.nodeId}`,
1457
+ {
1458
+ targetParentIdentity: resolved.parentIdentity,
1459
+ retainedCollision: {
1460
+ item: { nodeId: `cancel-source-${action.nodeId}` },
1461
+ lane: `cancel-source-${lane}`,
1462
+ outputOptions,
1463
+ },
1464
+ },
1465
+ );
1466
+ }
1467
+ for (const action of targetActions) {
1468
+ if (action.type !== 'create') continue;
1469
+ const cancellationStillPrivate = (
1470
+ exists(stagedPath(plan, 'creates', action.nodeId))
1471
+ || exists(stagedPath(
1472
+ plan,
1473
+ 'removed',
1474
+ `cancel-operation-create-${action.nodeId}`,
1475
+ ))
1476
+ );
1477
+ if (carriedCreateIds.has(action.nodeId) && !cancellationStillPrivate) {
1478
+ continue;
1479
+ }
1480
+ rollbackOperationCreate(
1481
+ plan,
1482
+ action,
1483
+ context,
1484
+ testHooks,
1485
+ outputOptions,
1486
+ );
1487
+ }
1488
+ cleanupStagingRoot(plan);
1489
+ if (
1490
+ carriedCreates.length
1491
+ || carriedCreateDeletions.length
1492
+ || (Array.isArray(plan.retainedOutputs) && plan.retainedOutputs.length)
1493
+ ) return true;
1494
+ const result = database.prepare(`
1495
+ DELETE FROM workspace_tree_operation_materializations
1496
+ WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'prepared'
1497
+ `).run(current.resourceId, current.mutationId);
1498
+ return result.changes === 1;
1499
+ }
1500
+
1501
+ function resolvedOperationAction(plan, action, phase, context) {
1502
+ if (!action[phase]) return action;
1503
+ const resolved = context.resolve(action, phase);
1504
+ if (!resolved) {
1505
+ throw parentRace(
1506
+ `workspace tree operation ${phase} parent changed for ${action.nodeId}`,
1507
+ );
1508
+ }
1509
+ return {
1510
+ ...action,
1511
+ [phase]: resolved.path,
1512
+ [`${phase}ParentIdentity`]: resolved.parentIdentity,
1513
+ };
1514
+ }
1515
+
1516
+ function stageOperationAction(plan, action, context, testHooks = {}) {
1517
+ const resolved = resolvedOperationAction(plan, action, 'source', context);
1518
+ const lane = action.type === 'move' ? 'move' : 'remove';
1519
+ const stagedLane = action.type === 'move' ? 'moves' : 'removed';
1520
+ stageOwnedPath({
1521
+ plan,
1522
+ source: resolved.source,
1523
+ staged: stagedPath(plan, stagedLane, action.nodeId),
1524
+ nodeId: action.nodeId,
1525
+ receiptLane: lane,
1526
+ expected: action.identity,
1527
+ sourceParentIdentity: resolved.sourceParentIdentity,
1528
+ operation: `operation-${lane}-stage-${action.nodeId}`,
1529
+ renameTestHooks: testHooks.operationStageRename || {},
1530
+ });
1531
+ }
1532
+
1533
+ function crossOperation(held, database = openLocalDb(), testHooks = {}) {
1534
+ if (!held) throw new Error('workspace tree operation plan is missing');
1535
+ const plan = held.plan;
1536
+ if (held.state === 'committed') return held;
1537
+ if (held.state === 'crossed') {
1538
+ const context = operationRecoveryContext(plan);
1539
+ for (const action of plan.actions) {
1540
+ const resolved = action.target
1541
+ ? resolvedOperationAction(plan, action, 'target', context)
1542
+ : action;
1543
+ if (
1544
+ action.type === 'create'
1545
+ && !ownedIdentity(
1546
+ resolved.target,
1547
+ readReceipt(plan, 'create', action.nodeId),
1548
+ )
1549
+ ) {
1550
+ throw new Error(`workspace tree create receipt changed for ${plan.mutationId}`);
1551
+ }
1552
+ if (
1553
+ action.type === 'move'
1554
+ && !ownedIdentity(
1555
+ resolved.target,
1556
+ readReceipt(plan, 'move-installed', action.nodeId),
1557
+ )
1558
+ ) {
1559
+ throw new Error(`workspace tree move receipt changed for ${plan.mutationId}`);
1560
+ }
1561
+ if (action.type === 'remove') {
1562
+ const staged = stagedPath(plan, 'removed', action.nodeId);
1563
+ if (!ownedIdentity(staged, readReceipt(plan, 'remove', action.nodeId))) {
1564
+ throw new Error(`workspace tree removal receipt changed for ${plan.mutationId}`);
1565
+ }
1566
+ }
1567
+ }
1568
+ return held;
1569
+ }
1570
+ requireRoot(plan, database);
1571
+ if (
1572
+ (Array.isArray(plan.carriedCreates) && plan.carriedCreates.length)
1573
+ || (
1574
+ Array.isArray(plan.carriedCreateDeletions)
1575
+ && plan.carriedCreateDeletions.length
1576
+ )
1577
+ || (
1578
+ Array.isArray(plan.retainedOutputs)
1579
+ && plan.retainedOutputs.length
1580
+ )
1581
+ ) {
1582
+ if (!cancelPreparedOperation(held, database, testHooks)) {
1583
+ throw new Error(
1584
+ `workspace tree carried create cancellation did not finish for ${plan.mutationId}`,
1585
+ );
1586
+ }
1587
+ const error = sourceRace(
1588
+ `workspace tree operation resumes from its carried create bindings for ${plan.mutationId}`,
1589
+ );
1590
+ error.operationPlan = (
1591
+ getOperation(plan.resourceId, plan.mutationId, database)?.plan
1592
+ || plan
1593
+ );
1594
+ throw error;
1595
+ }
1596
+ const cancellationStarted = plan.actions.some((action) => (
1597
+ action.type === 'create'
1598
+ && readReceipt(plan, 'create-cancelling', action.nodeId)
1599
+ ));
1600
+ if (cancellationStarted) {
1601
+ if (!cancelPreparedOperation(held, database, testHooks)) {
1602
+ throw new Error(
1603
+ `workspace tree operation cancellation did not finish for ${plan.mutationId}`,
1604
+ );
1605
+ }
1606
+ const error = sourceRace(
1607
+ `workspace tree operation resumes from its durable cancellation for ${plan.mutationId}`,
1608
+ );
1609
+ error.operationPlan = plan;
1610
+ throw error;
1611
+ }
1612
+ try {
1613
+ let context = operationRecoveryContext(plan);
1614
+ const extraction = plan.actions
1615
+ .filter((action) => action.type === 'move' || action.type === 'remove')
1616
+ .sort((left, right) => (
1617
+ right.source.split(path.sep).length - left.source.split(path.sep).length
1618
+ ));
1619
+ for (const action of extraction) {
1620
+ stageOperationAction(plan, action, context, testHooks);
1621
+ }
1622
+ if (typeof testHooks.afterOperationStaging === 'function') {
1623
+ testHooks.afterOperationStaging(plan);
1624
+ }
1625
+ context = operationRecoveryContext(plan);
1626
+ const installation = plan.actions
1627
+ .filter((action) => action.type === 'create' || action.type === 'move')
1628
+ .sort((left, right) => (
1629
+ left.target.split(path.sep).length - right.target.split(path.sep).length
1630
+ ));
1631
+ for (const action of installation) {
1632
+ const resolved = resolvedOperationAction(
1633
+ plan,
1634
+ action,
1635
+ 'target',
1636
+ context,
1637
+ );
1638
+ if (action.type === 'create') {
1639
+ crossCreateAction(plan, resolved, context, testHooks);
1640
+ } else {
1641
+ crossMoveAction(plan, resolved, testHooks);
1642
+ }
1643
+ }
1644
+ } catch (error) {
1645
+ if (
1646
+ error?.code === 'WORKSPACE_TREE_SOURCE_RACE'
1647
+ || error?.code === 'WORKSPACE_TREE_PARENT_RACE'
1648
+ ) {
1649
+ cancelPreparedOperation(held, database, testHooks);
1650
+ error.operationPlan = (
1651
+ getOperation(plan.resourceId, plan.mutationId, database)?.plan
1652
+ || plan
1653
+ );
1654
+ }
1655
+ throw error;
1656
+ }
1657
+ if (testHooks.crashAfterOperationPhysical === true) {
1658
+ throw new Error('simulated workspace tree crash after official operation');
1659
+ }
1660
+ database.prepare(`
1661
+ UPDATE workspace_tree_operation_materializations
1662
+ SET materialization_state = 'crossed',
1663
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1664
+ WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'prepared'
1665
+ `).run(held.resourceId, held.mutationId);
1666
+ return getOperation(held.resourceId, held.mutationId, database);
1667
+ }
1668
+
1669
+ function markOperationCommitted(held, database = openLocalDb()) {
1670
+ database.prepare(`
1671
+ UPDATE workspace_tree_operation_materializations
1672
+ SET materialization_state = 'committed',
1673
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1674
+ WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'crossed'
1675
+ `).run(held.resourceId, held.mutationId);
1676
+ const current = getOperation(held.resourceId, held.mutationId, database);
1677
+ if (!current || current.state !== 'committed') {
1678
+ throw new Error(`workspace tree operation ${held.mutationId} did not commit`);
1679
+ }
1680
+ return current;
1681
+ }
1682
+
1683
+ function retirementTransaction(plan, item, entry) {
1684
+ const spelledRoot = stagingRoot(plan);
1685
+ const root = fs.realpathSync(spelledRoot);
1686
+ const rootStat = fs.statSync(root, { bigint: true });
1687
+ const staged = stagedPath(plan, 'removed', item.nodeId);
1688
+ const candidate = entry.relativePath
1689
+ ? path.join(staged, ...entry.relativePath.split('/'))
1690
+ : staged;
1691
+ const relativePath = path.relative(spelledRoot, candidate).split(path.sep).join('/');
1692
+ const id = crypto.createHash('sha256')
1693
+ .update(
1694
+ `${plan.contract}\0retire\0${plan.resourceId}\0`
1695
+ + `${plan.snapshotVersion ?? plan.version}\0${plan.snapshotChecksum || plan.mutationId}`
1696
+ + `\0${item.nodeId}\0${entry.relativePath}`,
1697
+ )
1698
+ .digest('hex');
1699
+ return {
1700
+ candidate,
1701
+ scratch: path.join(path.dirname(candidate), `.amalgm-apply-${id}`),
1702
+ transaction: {
1703
+ root,
1704
+ rootDev: rootStat.dev.toString(),
1705
+ rootIno: rootStat.ino.toString(),
1706
+ path: relativePath,
1707
+ id,
1708
+ operation: `workspace-tree-retire:${plan.resourceId}:${item.nodeId}:${entry.relativePath}`,
1709
+ expected: entry.identity,
1710
+ target: null,
1711
+ },
1712
+ };
1713
+ }
1714
+
1715
+ function retireLeaf(plan, item, entry) {
1716
+ const prepared = retirementTransaction(plan, item, entry);
1717
+ let result;
1718
+ if (exists(prepared.scratch)) {
1719
+ result = nativeFileTransaction.run(prepared.transaction, 'recover');
1720
+ } else if (!exists(prepared.candidate)) {
1721
+ return false;
1722
+ } else {
1723
+ const stat = fs.lstatSync(prepared.candidate);
1724
+ if (!stat.isFile() && !stat.isSymbolicLink()) return true;
1725
+ result = nativeFileTransaction.run(prepared.transaction, 'apply');
1726
+ }
1727
+ if (result.status === 'applied') {
1728
+ const released = nativeFileTransaction.run(prepared.transaction, 'release');
1729
+ if (released.status !== 'released') {
1730
+ throw new Error(
1731
+ `workspace tree retirement receipt stayed ${released.status} for ${item.nodeId}`,
1732
+ );
1733
+ }
1734
+ return false;
1735
+ }
1736
+ if (result.status === 'changed') return exists(prepared.candidate);
1737
+ throw new Error(
1738
+ `workspace tree retirement stayed ${result.status} for ${item.nodeId}: `
1739
+ + `${result.reason || 'no reason'}`,
1740
+ );
1741
+ }
1742
+
1743
+ function removeEmptyDirectoryTree(target) {
1744
+ if (!exists(target)) return true;
1745
+ const stat = fs.lstatSync(target);
1746
+ if (!stat.isDirectory()) return false;
1747
+ for (const name of fs.readdirSync(target)) {
1748
+ const child = path.join(target, name);
1749
+ if (fs.lstatSync(child).isDirectory()) removeEmptyDirectoryTree(child);
1750
+ }
1751
+ if (fs.readdirSync(target).length !== 0) return false;
1752
+ fs.rmdirSync(target);
1753
+ syncDirectory(path.dirname(target));
1754
+ return true;
1755
+ }
1756
+
1757
+ function retainedConflictPath(target, token) {
1758
+ const directory = path.dirname(target);
1759
+ const extension = path.extname(target);
1760
+ const stem = extension ? path.basename(target, extension) : path.basename(target);
1761
+ let candidate = path.join(directory, `${stem}.amalgm-retained-${token}${extension}`);
1762
+ let suffix = 2;
1763
+ while (exists(candidate)) {
1764
+ candidate = path.join(
1765
+ directory,
1766
+ `${stem}.amalgm-retained-${token}-${suffix}${extension}`,
1767
+ );
1768
+ suffix += 1;
1769
+ }
1770
+ return candidate;
1771
+ }
1772
+
1773
+ function retainedRetry(message, reason) {
1774
+ return Object.assign(new Error(message), {
1775
+ code: 'WORKSPACE_TREE_RETRY_PENDING',
1776
+ reason,
1777
+ statusCode: 409,
1778
+ });
1779
+ }
1780
+
1781
+ function retainedOutputId(plan, item, lane, relativePath, role) {
1782
+ return crypto.createHash('sha256')
1783
+ .update(
1784
+ `${plan.contract}\0retained-output\0${plan.resourceId}\0`
1785
+ + `${plan.snapshotVersion ?? plan.version}\0`
1786
+ + `${plan.snapshotChecksum || plan.mutationId}\0${item.nodeId}\0`
1787
+ + `${lane}\0${relativePath}\0${role}`,
1788
+ )
1789
+ .digest('hex');
1790
+ }
1791
+
1792
+ function sealRetainedOutputs(plan, outputs, options = {}) {
1793
+ const current = Array.isArray(plan.retainedOutputs)
1794
+ ? [...plan.retainedOutputs]
1795
+ : [];
1796
+ const byId = new Map(current.map((output) => [output.outputId, output]));
1797
+ let changed = false;
1798
+ const sealed = outputs.map((output) => {
1799
+ const existing = byId.get(output.outputId);
1800
+ if (existing) {
1801
+ if (JSON.stringify(existing) !== JSON.stringify(output)) {
1802
+ throw Object.assign(
1803
+ retainedRetry(
1804
+ `workspace tree retained output ${output.outputId} changed its ownership receipt`,
1805
+ 'retained-output-changed',
1806
+ ),
1807
+ { paths: [existing.path, output.path].filter(Boolean) },
1808
+ );
1809
+ }
1810
+ return existing;
1811
+ }
1812
+ current.push(output);
1813
+ byId.set(output.outputId, output);
1814
+ changed = true;
1815
+ return output;
1816
+ });
1817
+ if (!changed) return sealed;
1818
+ current.sort((left, right) => left.outputId.localeCompare(right.outputId));
1819
+ plan.retainedOutputs = current;
1820
+ if (typeof options.preparePlan === 'function') {
1821
+ options.preparePlan(plan, sealed);
1822
+ }
1823
+ if (options.durable === true && typeof options.persistPlan !== 'function') {
1824
+ throw new Error('workspace tree retained output has no durable plan writer');
1825
+ }
1826
+ if (typeof options.persistPlan === 'function') options.persistPlan(plan);
1827
+ if (typeof options.afterOutputReceipt === 'function') {
1828
+ options.afterOutputReceipt(sealed, plan);
1829
+ }
1830
+ return sealed;
1831
+ }
1832
+
1833
+ function retainedOutput(
1834
+ plan,
1835
+ item,
1836
+ lane,
1837
+ relativePath,
1838
+ role,
1839
+ outputPath,
1840
+ physicalIdentity,
1841
+ sourceRelativePath,
1842
+ parentIdentity = null,
1843
+ ownerNodeId = null,
1844
+ ) {
1845
+ return {
1846
+ outputId: retainedOutputId(plan, item, lane, relativePath, role),
1847
+ removalNodeId: item.nodeId,
1848
+ lane,
1849
+ relativePath,
1850
+ path: outputPath,
1851
+ identity: physicalIdentity,
1852
+ provenance: role === 'carried' ? 'carried-source' : 'independent-occupant',
1853
+ ownerNodeId: role === 'carried' ? ownerNodeId : null,
1854
+ sourceRelativePath: role === 'carried' ? sourceRelativePath : null,
1855
+ parentIdentity: parentIdentity || identity(path.dirname(outputPath), { deep: false }),
1856
+ };
1857
+ }
1858
+
1859
+ function retainedTreeOutputs(
1860
+ plan,
1861
+ item,
1862
+ lane,
1863
+ source,
1864
+ target,
1865
+ relativePath,
1866
+ role,
1867
+ sourceRelativePath,
1868
+ ) {
1869
+ const outputs = [];
1870
+ const ownerBySourcePath = new Map(
1871
+ (plan.rollbackBindings || [])
1872
+ .filter((binding) => binding?.from != null && binding?.nodeId)
1873
+ .map((binding) => [binding.from, binding.nodeId]),
1874
+ );
1875
+ const carriedOwner = (outputSourceRelativePath) => {
1876
+ if (role !== 'carried') return null;
1877
+ const sourcePath = item.from == null
1878
+ ? null
1879
+ : (
1880
+ outputSourceRelativePath
1881
+ ? `${item.from}/${outputSourceRelativePath}`
1882
+ : item.from
1883
+ );
1884
+ if (sourcePath && ownerBySourcePath.has(sourcePath)) {
1885
+ return ownerBySourcePath.get(sourcePath);
1886
+ }
1887
+ if (!outputSourceRelativePath && (item.ownerNodeId || item.nodeId)) {
1888
+ return item.ownerNodeId || item.nodeId;
1889
+ }
1890
+ return null;
1891
+ };
1892
+ const visit = (
1893
+ sourcePath,
1894
+ targetPath,
1895
+ outputRelativePath,
1896
+ outputSourceRelativePath,
1897
+ ) => {
1898
+ const sourceIdentity = identity(sourcePath, { deep: false });
1899
+ const outputParentIdentity = exists(path.dirname(targetPath))
1900
+ ? identity(path.dirname(targetPath), { deep: false })
1901
+ : identity(path.dirname(sourcePath), { deep: false });
1902
+ outputs.push(retainedOutput(
1903
+ plan,
1904
+ item,
1905
+ lane,
1906
+ outputRelativePath,
1907
+ role,
1908
+ targetPath,
1909
+ sourceIdentity,
1910
+ role === 'carried' ? outputSourceRelativePath : null,
1911
+ outputParentIdentity,
1912
+ carriedOwner(outputSourceRelativePath),
1913
+ ));
1914
+ if (sourceIdentity.kind !== 'folder') return;
1915
+ for (const name of fs.readdirSync(sourcePath).sort()) {
1916
+ visit(
1917
+ path.join(sourcePath, name),
1918
+ path.join(targetPath, name),
1919
+ outputRelativePath ? `${outputRelativePath}/${name}` : name,
1920
+ outputSourceRelativePath ? `${outputSourceRelativePath}/${name}` : name,
1921
+ );
1922
+ }
1923
+ };
1924
+ visit(source, target, relativePath, sourceRelativePath);
1925
+ return outputs;
1926
+ }
1927
+
1928
+ function mergeRetained(
1929
+ plan,
1930
+ item,
1931
+ source,
1932
+ target,
1933
+ token,
1934
+ options = {},
1935
+ relativePath = '',
1936
+ ) {
1937
+ if (!exists(source)) return [];
1938
+ const sourceIdentity = identity(source, { deep: false });
1939
+ const sourceRelativePath = options.sourceRelativePath == null
1940
+ ? relativePath
1941
+ : (
1942
+ relativePath
1943
+ ? `${options.sourceRelativePath}/${relativePath}`
1944
+ : options.sourceRelativePath
1945
+ );
1946
+ if (!exists(target)) {
1947
+ if (options.targetParentIdentity) {
1948
+ requireOwnedDirectory(
1949
+ path.dirname(target),
1950
+ options.targetParentIdentity,
1951
+ 'retained output',
1952
+ );
1953
+ } else {
1954
+ ensureOperationDirectory(path.dirname(target));
1955
+ }
1956
+ const role = options.provenance === 'independent-occupant'
1957
+ ? 'occupant'
1958
+ : 'carried';
1959
+ const sealed = sealRetainedOutputs(
1960
+ plan,
1961
+ retainedTreeOutputs(
1962
+ plan,
1963
+ item,
1964
+ options.lane,
1965
+ source,
1966
+ target,
1967
+ relativePath,
1968
+ role,
1969
+ sourceRelativePath,
1970
+ ),
1971
+ options,
1972
+ );
1973
+ const carried = sealed.find((output) => output.relativePath === relativePath);
1974
+ try {
1975
+ exclusiveRename(
1976
+ plan,
1977
+ source,
1978
+ carried.path,
1979
+ `retain-install-${token}`,
1980
+ sourceIdentity,
1981
+ options.targetParentIdentity
1982
+ ? { targetParentIdentity: options.targetParentIdentity }
1983
+ : {},
1984
+ );
1985
+ } catch (error) {
1986
+ if (options.exclusive === true && exists(target)) {
1987
+ throw retainedRetry(
1988
+ `workspace tree retained target became occupied: ${target}`,
1989
+ 'retained-target-occupied',
1990
+ );
1991
+ }
1992
+ throw error;
1993
+ }
1994
+ if (typeof options.afterOutputMove === 'function') {
1995
+ options.afterOutputMove([carried], plan);
1996
+ }
1997
+ return [carried];
1998
+ }
1999
+ if (options.exclusive === true) {
2000
+ throw retainedRetry(
2001
+ `workspace tree retained target is occupied: ${target}`,
2002
+ 'retained-target-occupied',
2003
+ );
2004
+ }
2005
+ const sourceStat = fs.lstatSync(source);
2006
+ const targetStat = fs.lstatSync(target);
2007
+ if (sourceStat.isDirectory() && targetStat.isDirectory()) {
2008
+ const retained = sealRetainedOutputs(
2009
+ plan,
2010
+ retainedTreeOutputs(
2011
+ plan,
2012
+ item,
2013
+ options.lane,
2014
+ target,
2015
+ target,
2016
+ relativePath,
2017
+ 'occupant',
2018
+ '',
2019
+ ),
2020
+ options,
2021
+ );
2022
+ for (const name of fs.readdirSync(source).sort()) {
2023
+ retained.push(...mergeRetained(
2024
+ plan,
2025
+ item,
2026
+ path.join(source, name),
2027
+ path.join(target, name),
2028
+ token,
2029
+ options,
2030
+ relativePath ? `${relativePath}/${name}` : name,
2031
+ ));
2032
+ }
2033
+ removeEmptyDirectoryTree(source);
2034
+ return retained;
2035
+ }
2036
+ const outputId = retainedOutputId(
2037
+ plan,
2038
+ item,
2039
+ options.lane,
2040
+ relativePath,
2041
+ 'carried',
2042
+ );
2043
+ const existing = (plan.retainedOutputs || []).find(
2044
+ (output) => output.outputId === outputId,
2045
+ );
2046
+ const conflict = existing?.path || retainedConflictPath(target, token);
2047
+ const sourceRole = options.provenance === 'independent-occupant'
2048
+ ? 'occupant'
2049
+ : 'carried';
2050
+ const outputs = sealRetainedOutputs(plan, [
2051
+ ...retainedTreeOutputs(
2052
+ plan,
2053
+ item,
2054
+ options.lane,
2055
+ target,
2056
+ target,
2057
+ relativePath,
2058
+ 'occupant',
2059
+ '',
2060
+ ),
2061
+ ...retainedTreeOutputs(
2062
+ plan,
2063
+ item,
2064
+ options.lane,
2065
+ source,
2066
+ conflict,
2067
+ relativePath,
2068
+ sourceRole,
2069
+ sourceRelativePath,
2070
+ ),
2071
+ ], options);
2072
+ const carriedRoot = outputs.find((output) => (
2073
+ output.path === conflict
2074
+ && output.provenance === (
2075
+ sourceRole === 'carried' ? 'carried-source' : 'independent-occupant'
2076
+ )
2077
+ ));
2078
+ exclusiveRename(
2079
+ plan,
2080
+ source,
2081
+ carriedRoot.path,
2082
+ `retain-conflict-${token}`,
2083
+ sourceIdentity,
2084
+ );
2085
+ if (typeof options.afterOutputMove === 'function') {
2086
+ options.afterOutputMove(outputs, plan);
2087
+ }
2088
+ return outputs;
2089
+ }
2090
+
2091
+ function retireRemoval(plan, item, options = {}) {
2092
+ const staged = stagedPath(plan, 'removed', item.nodeId);
2093
+ const source = item.source || absolute(plan, item.from);
2094
+ const target = item.retainTarget || source;
2095
+ if (
2096
+ item.retainTargetExclusive === true
2097
+ && exists(target)
2098
+ && (exists(staged) || (source !== target && exists(source)))
2099
+ ) {
2100
+ throw retainedRetry(
2101
+ `workspace tree retained target is occupied: ${target}`,
2102
+ 'retained-target-occupied',
2103
+ );
2104
+ }
2105
+ if (!item.retirement || !Array.isArray(item.retirement.entries)) {
2106
+ throw new Error(`workspace tree removal ${item.nodeId} has no leaf retirement proof`);
2107
+ }
2108
+ for (const entry of [...item.retirement.entries].sort((left, right) => (
2109
+ right.relativePath.split('/').length - left.relativePath.split('/').length
2110
+ ))) {
2111
+ retireLeaf(plan, item, entry);
2112
+ }
2113
+ removeEmptyDirectoryTree(staged);
2114
+ const token = crypto.createHash('sha256')
2115
+ .update(
2116
+ `${plan.snapshotChecksum || plan.mutationId}\0${item.nodeId}`,
2117
+ )
2118
+ .digest('hex')
2119
+ .slice(0, 12);
2120
+ const retainedOutputs = [
2121
+ ...mergeRetained(plan, item, staged, target, token, {
2122
+ ...options,
2123
+ exclusive: item.retainTargetExclusive === true,
2124
+ targetParentIdentity: item.retainTargetParentIdentity || null,
2125
+ lane: 'staged',
2126
+ provenance: 'carried-source',
2127
+ }),
2128
+ ...(source === target ? [] : mergeRetained(plan, item, source, target, token, {
2129
+ ...options,
2130
+ exclusive: item.retainTargetExclusive === true,
2131
+ targetParentIdentity: item.retainTargetParentIdentity || null,
2132
+ lane: 'visible',
2133
+ provenance: 'independent-occupant',
2134
+ })),
2135
+ ];
2136
+ const existingOutputs = (plan.retainedOutputs || []).filter(
2137
+ (output) => output.removalNodeId === item.nodeId,
2138
+ );
2139
+ if (
2140
+ source === target
2141
+ && exists(target)
2142
+ && ![...retainedOutputs, ...existingOutputs].some(
2143
+ (output) => output.path === target,
2144
+ )
2145
+ ) {
2146
+ retainedOutputs.push(...sealRetainedOutputs(
2147
+ plan,
2148
+ retainedTreeOutputs(
2149
+ plan,
2150
+ item,
2151
+ 'visible',
2152
+ target,
2153
+ target,
2154
+ '',
2155
+ 'occupant',
2156
+ ),
2157
+ options,
2158
+ ));
2159
+ }
2160
+ const allOutputs = Array.from(new Map(
2161
+ [...existingOutputs, ...retainedOutputs]
2162
+ .map((output) => [output.outputId, output]),
2163
+ ).values());
2164
+ return {
2165
+ nodeId: item.nodeId,
2166
+ target,
2167
+ retainedPaths: Array.from(new Set(allOutputs.map((output) => output.path))),
2168
+ retainedOutputs: allOutputs,
2169
+ retained: allOutputs.length > 0,
2170
+ };
2171
+ }
2172
+
2173
+ function cleanupStagingRoot(plan) {
2174
+ const root = stagingRoot(plan);
2175
+ if (!exists(root)) return;
2176
+ for (const lane of ['creates', 'moves', 'move-successors', 'removed']) {
2177
+ const target = path.join(root, lane);
2178
+ if (!removeEmptyDirectoryTree(target)) {
2179
+ throw new Error(`workspace tree staging lane still contains bytes: ${target}`);
2180
+ }
2181
+ }
2182
+ // Receipts remain until every byte lane is durably empty. A crash during
2183
+ // cleanup can therefore rerun classification; it can never strand staged
2184
+ // bytes after deleting the evidence that owns them.
2185
+ const receipts = path.join(root, 'receipts');
2186
+ if (exists(receipts)) fs.rmSync(receipts, { recursive: true, force: true });
2187
+ if (!removeEmptyDirectoryTree(root)) {
2188
+ throw new Error(`workspace tree staging still contains unclassified bytes: ${root}`);
2189
+ }
2190
+ syncDirectory(path.dirname(plan.rootPath));
2191
+ }
2192
+
2193
+ function initialCreateRetirement(action, receipt) {
2194
+ const content = action.kind === 'symlink'
2195
+ ? Buffer.from(action.symlinkTarget)
2196
+ : Buffer.alloc(0);
2197
+ return {
2198
+ entries: [{
2199
+ relativePath: '',
2200
+ identity: {
2201
+ kind: action.kind,
2202
+ exec: false,
2203
+ bytes: content.length,
2204
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
2205
+ ...((action.kind === 'file' || action.kind === 'symlink') ? {
2206
+ dev: receipt.dev,
2207
+ ino: receipt.ino,
2208
+ } : {}),
2209
+ },
2210
+ }],
2211
+ directories: [],
2212
+ };
2213
+ }
2214
+
2215
+ function rollbackCreatedNode(plan, action, testHooks = {}, outputOptions = {}) {
2216
+ const receipt = readReceipt(plan, 'creates', action.nodeId);
2217
+ if (!receipt) return;
2218
+ const prepared = stagedPath(plan, 'creates', action.nodeId);
2219
+ const target = absolute(plan, action.to);
2220
+ const cancelId = `cancel-${action.nodeId}`;
2221
+ const cancelled = stagedPath(plan, 'removed', cancelId);
2222
+ if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
2223
+ retainPrivatePath(
2224
+ plan,
2225
+ cancelled,
2226
+ target,
2227
+ `rollback-create-unexpected-${action.nodeId}`,
2228
+ {
2229
+ item: { nodeId: `rollback-create-unexpected-${action.nodeId}` },
2230
+ lane: 'rollback-create-unexpected',
2231
+ outputOptions,
2232
+ },
2233
+ );
2234
+ }
2235
+ if (exists(prepared) && !ownedIdentity(prepared, receipt)) {
2236
+ retainPrivatePath(
2237
+ plan,
2238
+ prepared,
2239
+ target,
2240
+ `rollback-create-prepared-${action.nodeId}`,
2241
+ {
2242
+ item: { nodeId: `rollback-create-prepared-${action.nodeId}` },
2243
+ lane: 'rollback-create-prepared',
2244
+ outputOptions,
2245
+ },
2246
+ );
2247
+ }
2248
+ if (ownedIdentity(prepared, receipt) && !ownedIdentity(cancelled, receipt)) {
2249
+ ensureDirectory(plan, path.dirname(cancelled));
2250
+ writeReceipt(plan, 'rollback-create', action.nodeId, receipt);
2251
+ exclusiveRename(
2252
+ plan,
2253
+ prepared,
2254
+ cancelled,
2255
+ `rollback-create-prepared-${action.nodeId}`,
2256
+ receipt,
2257
+ );
2258
+ }
2259
+ if (
2260
+ !ownedIdentity(cancelled, receipt)
2261
+ && readReceipt(plan, 'create-installing', action.nodeId)
2262
+ && ownedIdentity(target, receipt)
2263
+ ) {
2264
+ if (typeof testHooks.beforeRollbackCreatedFolderRetire === 'function') {
2265
+ testHooks.beforeRollbackCreatedFolderRetire(target, action);
2266
+ }
2267
+ ensureDirectory(plan, path.dirname(cancelled));
2268
+ writeReceipt(plan, 'rollback-create', action.nodeId, receipt);
2269
+ exclusiveRename(
2270
+ plan,
2271
+ target,
2272
+ cancelled,
2273
+ `rollback-create-installed-${action.nodeId}`,
2274
+ receipt,
2275
+ {
2276
+ sourceParentIdentity: plannedParentIdentity(
2277
+ plan,
2278
+ action.targetParentNodeId,
2279
+ 'target',
2280
+ ),
2281
+ allowChanged: true,
2282
+ testHooks: testHooks.rollbackCreatedRename || {},
2283
+ },
2284
+ );
2285
+ }
2286
+ if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
2287
+ retainPrivatePath(
2288
+ plan,
2289
+ cancelled,
2290
+ target,
2291
+ `rollback-create-raced-${action.nodeId}`,
2292
+ {
2293
+ item: { nodeId: `rollback-create-raced-${action.nodeId}` },
2294
+ lane: 'rollback-create-raced',
2295
+ outputOptions,
2296
+ },
2297
+ );
2298
+ }
2299
+ if (!ownedIdentity(cancelled, receipt)) return;
2300
+ retireRemoval(plan, {
2301
+ nodeId: cancelId,
2302
+ source: target,
2303
+ retainTarget: target,
2304
+ retirement: initialCreateRetirement(action, receipt),
2305
+ }, outputOptions);
2306
+ }
2307
+
2308
+ function moveAside(plan, target, token, options = {}) {
2309
+ if (!options.item || !options.lane) {
2310
+ if (!exists(target)) return null;
2311
+ const conflict = retainedConflictPath(target, token);
2312
+ exclusiveRename(plan, target, conflict, `move-aside-${token}`);
2313
+ return conflict;
2314
+ }
2315
+ const existingOutputs = (plan.retainedOutputs || [])
2316
+ .filter((output) => (
2317
+ output.removalNodeId === options.item.nodeId
2318
+ && output.lane === options.lane
2319
+ && output.provenance === 'independent-occupant'
2320
+ ))
2321
+ .sort((left, right) => left.outputId.localeCompare(right.outputId));
2322
+ if (!exists(target)) {
2323
+ const retained = existingOutputs.filter((output) => (
2324
+ ownedIdentity(output.path, output.identity)
2325
+ ));
2326
+ return retained.length === 1 ? retained[0].path : null;
2327
+ }
2328
+ const targetIdentity = identity(target, { deep: false });
2329
+ const relativePath = (
2330
+ `entry-${targetIdentity.kind}-${targetIdentity.dev}-${targetIdentity.ino}`
2331
+ );
2332
+ const outputId = retainedOutputId(
2333
+ plan,
2334
+ options.item,
2335
+ options.lane,
2336
+ relativePath,
2337
+ 'occupant',
2338
+ );
2339
+ const existing = existingOutputs.find((output) => output.outputId === outputId);
2340
+ const conflict = existing?.path || retainedConflictPath(target, token);
2341
+ const retained = mergeRetained(
2342
+ plan,
2343
+ options.item,
2344
+ target,
2345
+ conflict,
2346
+ token,
2347
+ {
2348
+ ...(options.outputOptions || {}),
2349
+ lane: options.lane,
2350
+ provenance: 'independent-occupant',
2351
+ exclusive: true,
2352
+ },
2353
+ relativePath,
2354
+ );
2355
+ return retained.find((output) => output.outputId === outputId)?.path || conflict;
2356
+ }
2357
+
2358
+ function moveHeldAside(plan, held, basisTarget, token) {
2359
+ if (!exists(held)) return null;
2360
+ if (held === basisTarget) return moveAside(plan, held, token);
2361
+ const conflict = retainedConflictPath(basisTarget, token);
2362
+ requireExistingDirectory(plan, path.dirname(conflict), token);
2363
+ exclusiveRename(plan, held, conflict, `move-held-aside-${token}`);
2364
+ return conflict;
2365
+ }
2366
+
2367
+ function duplicateRetirementId(plan, token) {
2368
+ return crypto.createHash('sha256')
2369
+ .update(
2370
+ `${plan.contract}\0duplicate-entry\0${plan.resourceId}\0`
2371
+ + `${plan.snapshotVersion ?? plan.version}\0`
2372
+ + `${plan.snapshotChecksum || plan.mutationId}\0${token}`,
2373
+ )
2374
+ .digest('hex');
2375
+ }
2376
+
2377
+ function duplicateScratchPath(plan, held, token) {
2378
+ return path.join(
2379
+ path.dirname(held),
2380
+ `.amalgm-apply-${duplicateRetirementId(plan, token)}`,
2381
+ );
2382
+ }
2383
+
2384
+ function retireDuplicateEntry(plan, held, linkedTarget, receipt, token) {
2385
+ const id = duplicateRetirementId(plan, token);
2386
+ const scratch = duplicateScratchPath(plan, held, token);
2387
+ const scratchSource = path.join(scratch, 'source');
2388
+ const identitySource = ownedIdentity(held, receipt)
2389
+ ? held
2390
+ : ownedIdentity(linkedTarget, receipt)
2391
+ ? linkedTarget
2392
+ : ownedIdentity(scratchSource, receipt)
2393
+ ? scratchSource
2394
+ : null;
2395
+ let expected = readReceipt(plan, 'duplicate', id);
2396
+ if (!expected) {
2397
+ if (!identitySource) return false;
2398
+ expected = writeReceipt(
2399
+ plan,
2400
+ 'duplicate',
2401
+ id,
2402
+ nativeByteIdentity(identitySource),
2403
+ );
2404
+ }
2405
+ const root = fs.realpathSync(path.dirname(held));
2406
+ const rootStat = fs.lstatSync(root, { bigint: true });
2407
+ const transaction = {
2408
+ root,
2409
+ rootDev: rootStat.dev.toString(),
2410
+ rootIno: rootStat.ino.toString(),
2411
+ path: path.basename(held),
2412
+ id,
2413
+ operation: `workspace-tree-duplicate-entry:${plan.resourceId}:${token}`,
2414
+ expected,
2415
+ target: null,
2416
+ };
2417
+ if (!exists(held) && !exists(scratch)) return true;
2418
+ const result = nativeFileTransaction.run(
2419
+ transaction,
2420
+ exists(scratch) ? 'recover' : 'apply',
2421
+ );
2422
+ if (result.status === 'changed') return !exists(held) && !exists(scratch);
2423
+ if (result.status !== 'applied') {
2424
+ throw new Error(
2425
+ `workspace tree duplicate entry stayed ${result.status}: `
2426
+ + `${result.reason || 'no reason'}`,
2427
+ );
2428
+ }
2429
+ const released = nativeFileTransaction.run(transaction, 'release');
2430
+ if (released.status !== 'released') {
2431
+ throw new Error(
2432
+ `workspace tree duplicate entry receipt stayed ${released.status}: `
2433
+ + `${released.reason || 'no reason'}`,
2434
+ );
2435
+ }
2436
+ return true;
2437
+ }
2438
+
2439
+ function installOwnedPath(plan, held, target, receipt, token, options = {}) {
2440
+ if (options.targetParentIdentity) {
2441
+ requireOwnedDirectory(
2442
+ path.dirname(target),
2443
+ options.targetParentIdentity,
2444
+ 'install',
2445
+ );
2446
+ }
2447
+ if (exists(duplicateScratchPath(plan, held, token))) {
2448
+ if (!retireDuplicateEntry(plan, held, target, receipt, token)) {
2449
+ throw new Error(`workspace tree duplicate recovery changed for ${token}`);
2450
+ }
2451
+ if (!ownedIdentity(target, receipt) && !ownedIdentity(held, receipt)) {
2452
+ const error = sourceRace(
2453
+ `workspace tree target changed after duplicate extraction for ${token}`,
2454
+ );
2455
+ if (exists(target)) {
2456
+ error.duplicateSuccessor = {
2457
+ path: target,
2458
+ identity: identity(target, { deep: false }),
2459
+ };
2460
+ }
2461
+ throw error;
2462
+ }
2463
+ }
2464
+ if (ownedIdentity(target, receipt)) {
2465
+ // File identity names an inode, not one directory entry. A local hardlink
2466
+ // may have restored the source while the operation-owned staging name
2467
+ // still references the same inode. Consume only that staging entry; the
2468
+ // destination keeps the bytes and UUID, and cleanup cannot wedge.
2469
+ if (
2470
+ held !== target
2471
+ && ownedIdentity(held, receipt)
2472
+ ) {
2473
+ const stat = fs.lstatSync(held);
2474
+ if (stat.isDirectory()) {
2475
+ throw new Error(`workspace tree receipt has duplicate directory entries: ${held}`);
2476
+ }
2477
+ if (!retireDuplicateEntry(plan, held, target, receipt, token)) {
2478
+ throw new Error(`workspace tree duplicate entry changed for ${token}`);
2479
+ }
2480
+ }
2481
+ return true;
2482
+ }
2483
+ if (!ownedIdentity(held, receipt)) return false;
2484
+ if (exists(target)) {
2485
+ moveAside(
2486
+ plan,
2487
+ target,
2488
+ `${token}-collision`,
2489
+ options.retainedCollision || {},
2490
+ );
2491
+ }
2492
+ if (options.operationParent === true) {
2493
+ requireOwnedDirectory(
2494
+ path.dirname(target),
2495
+ options.targetParentIdentity,
2496
+ 'operation install',
2497
+ );
2498
+ } else {
2499
+ requireExistingDirectory(plan, path.dirname(target), token);
2500
+ }
2501
+ exclusiveRename(
2502
+ plan,
2503
+ held,
2504
+ target,
2505
+ `install-owned-${token}`,
2506
+ receipt,
2507
+ {
2508
+ ...(options.sourceParentIdentity
2509
+ ? { sourceParentIdentity: options.sourceParentIdentity }
2510
+ : {}),
2511
+ ...(options.targetParentIdentity
2512
+ ? { targetParentIdentity: options.targetParentIdentity }
2513
+ : {}),
2514
+ ...(options.testHooks ? { testHooks: options.testHooks } : {}),
2515
+ },
2516
+ );
2517
+ return true;
2518
+ }
2519
+
2520
+ function receiptIdentityKey(value) {
2521
+ return value ? `${value.kind}\0${value.dev}\0${value.ino}` : null;
2522
+ }
2523
+
2524
+ function indexProjectedBindingLocations(plan, options = {}) {
2525
+ const bindings = [
2526
+ ...(plan.rollbackBindings || []),
2527
+ ...(plan.projectedBindings || []),
2528
+ ];
2529
+ const wanted = new Set(
2530
+ [
2531
+ ...bindings.map((item) => item.identity),
2532
+ ...(options.identities || []),
2533
+ ]
2534
+ .map(receiptIdentityKey)
2535
+ .filter(Boolean),
2536
+ );
2537
+ const locations = new Map();
2538
+ const visit = (directory, parentRelativePath) => {
2539
+ let names;
2540
+ try {
2541
+ names = fs.readdirSync(directory).sort();
2542
+ } catch (error) {
2543
+ if (isPathAbsentError(error)) return;
2544
+ throw error;
2545
+ }
2546
+ for (const name of names) {
2547
+ const relativePath = parentRelativePath
2548
+ ? `${parentRelativePath}/${name}`
2549
+ : name;
2550
+ if (!shouldEnroll(relativePath)) continue;
2551
+ const candidate = path.join(directory, name);
2552
+ if (typeof options.onVisit === 'function') {
2553
+ options.onVisit(candidate, relativePath);
2554
+ }
2555
+ let stat;
2556
+ try {
2557
+ stat = fs.lstatSync(candidate, { bigint: true });
2558
+ } catch (error) {
2559
+ if (isPathAbsentError(error)) continue;
2560
+ throw error;
2561
+ }
2562
+ const kind = stat.isSymbolicLink()
2563
+ ? 'symlink'
2564
+ : stat.isDirectory()
2565
+ ? 'folder'
2566
+ : stat.isFile()
2567
+ ? 'file'
2568
+ : 'other';
2569
+ const key = `${kind}\0${stat.dev}\0${stat.ino}`;
2570
+ if (wanted.has(key)) {
2571
+ const held = locations.get(key) || [];
2572
+ held.push(candidate);
2573
+ locations.set(key, held);
2574
+ }
2575
+ if (
2576
+ kind === 'folder'
2577
+ && !exists(path.join(candidate, '.git'))
2578
+ ) {
2579
+ visit(candidate, relativePath);
2580
+ }
2581
+ }
2582
+ };
2583
+ visit(plan.rootPath, '');
2584
+ return locations;
2585
+ }
2586
+
2587
+ function locateOperationBindings(plan, bindings) {
2588
+ const values = Array.isArray(bindings) ? bindings : [];
2589
+ const indexed = indexProjectedBindingLocations(plan, {
2590
+ identities: values.map((binding) => binding.identity).filter(Boolean),
2591
+ });
2592
+ return new Map(values.map((binding) => {
2593
+ const candidates = Array.from(new Set([
2594
+ binding.path,
2595
+ ...(indexed.get(receiptIdentityKey(binding.identity)) || []),
2596
+ ].filter(Boolean))).filter((candidate) => (
2597
+ ownedIdentity(candidate, binding.identity)
2598
+ ));
2599
+ return [binding.nodeId, candidates];
2600
+ }));
2601
+ }
2602
+
2603
+ function moveRecoveryContext(plan, options = {}) {
2604
+ const bindingsById = new Map(
2605
+ [
2606
+ ...(plan.rollbackBindings || []),
2607
+ ...(plan.projectedBindings || []),
2608
+ ].map((item) => [item.nodeId, item]),
2609
+ );
2610
+ const movesById = new Map(
2611
+ (plan.moves || []).map((item) => [item.nodeId, item]),
2612
+ );
2613
+ let indexed = indexProjectedBindingLocations(plan, options);
2614
+ let refreshed = false;
2615
+ const indexedMatches = (expected) => {
2616
+ const candidates = () => indexed.get(receiptIdentityKey(expected)) || [];
2617
+ const find = () => candidates()
2618
+ .filter((candidate) => ownedIdentity(candidate, expected));
2619
+ let matches = find();
2620
+ if (matches.length) return matches;
2621
+ // The first unresolved identity seals one fresh, shared absence scan for
2622
+ // the whole classification phase. Every later absence is answered from
2623
+ // that same observation. If an entry named by the sealed scan moves
2624
+ // again, abort this bounded attempt rather than treating stale cache as
2625
+ // permission to adopt an unrelated endpoint.
2626
+ if (!refreshed) {
2627
+ indexed = indexProjectedBindingLocations(plan, options);
2628
+ refreshed = true;
2629
+ matches = find();
2630
+ }
2631
+ if (!matches.length && refreshed && candidates().length > 0) {
2632
+ throw sourceRace('workspace tree recovery changed after its sealed scan');
2633
+ }
2634
+ return matches;
2635
+ };
2636
+ const location = (binding) => {
2637
+ if (!binding) return null;
2638
+ const move = movesById.get(binding.nodeId);
2639
+ const expected = move
2640
+ ? readReceipt(plan, 'moves', binding.nodeId) || binding.identity
2641
+ : binding.identity;
2642
+ const exactCandidates = move
2643
+ ? [
2644
+ stagedPath(plan, 'moves', move.nodeId),
2645
+ absolute(plan, move.to),
2646
+ absolute(plan, move.from),
2647
+ absolute(plan, binding.to),
2648
+ absolute(plan, binding.from),
2649
+ ]
2650
+ : [
2651
+ absolute(plan, binding.to),
2652
+ absolute(plan, binding.from),
2653
+ ];
2654
+ const exact = exactCandidates.find(
2655
+ (candidate) => ownedIdentity(candidate, expected),
2656
+ );
2657
+ if (exact) return exact;
2658
+ const matches = indexedMatches(expected);
2659
+ // Multiple hardlink names may make the final UUID path ambiguous, but
2660
+ // they still prove the original inode exists. Never turn ambiguity into
2661
+ // permission to adopt an unrelated stale target as the successor.
2662
+ return matches[0] || null;
2663
+ };
2664
+ return {
2665
+ ownedLocation(item) {
2666
+ return location(bindingsById.get(item.nodeId));
2667
+ },
2668
+ targetCarriedLocation(item) {
2669
+ const carrier = bindingsById.get(item.targetCarrierNodeId);
2670
+ const carrierLocation = location(carrier);
2671
+ if (!carrierLocation || !item.targetCarrierSuffix) return null;
2672
+ const candidate = path.join(
2673
+ carrierLocation,
2674
+ ...item.targetCarrierSuffix.split('/'),
2675
+ );
2676
+ const receipt = readReceipt(plan, 'moves', item.nodeId);
2677
+ return ownedIdentity(candidate, receipt) ? candidate : null;
2678
+ },
2679
+ sourcePath(item) {
2680
+ if (
2681
+ !item.sourceParentNodeId
2682
+ || item.sourceParentNodeId === plan.resourceId
2683
+ ) {
2684
+ return path.join(plan.rootPath, path.basename(item.from));
2685
+ }
2686
+ const directParent = bindingsById.get(item.sourceParentNodeId);
2687
+ const directParentLocation = location(directParent);
2688
+ if (directParentLocation) {
2689
+ return path.join(directParentLocation, path.basename(item.from));
2690
+ }
2691
+ const carrier = bindingsById.get(item.sourceCarrierNodeId);
2692
+ const carrierLocation = location(carrier);
2693
+ if (!carrierLocation || !item.sourceCarrierSuffix) {
2694
+ return absolute(plan, item.from);
2695
+ }
2696
+ return path.join(
2697
+ carrierLocation,
2698
+ ...item.sourceCarrierSuffix.split('/'),
2699
+ );
2700
+ },
2701
+ sourceParentIdentity(item) {
2702
+ if (
2703
+ !item.sourceParentNodeId
2704
+ || item.sourceParentNodeId === plan.resourceId
2705
+ ) return plan.rootIdentity;
2706
+ return bindingsById.get(item.sourceParentNodeId)?.identity || null;
2707
+ },
2708
+ };
2709
+ }
2710
+
2711
+ function reclassifyUnexpectedMoveStage(plan, item, staged, receipt) {
2712
+ if (!exists(staged) || ownedIdentity(staged, receipt)) return false;
2713
+ const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
2714
+ if (exists(successorStaged)) {
2715
+ throw new Error(`workspace tree move has two unclassified successors for ${item.nodeId}`);
2716
+ }
2717
+ exclusiveRename(
2718
+ plan,
2719
+ staged,
2720
+ successorStaged,
2721
+ `rollback-raced-successor-${item.nodeId}`,
2722
+ );
2723
+ writeReceipt(
2724
+ plan,
2725
+ 'move-successor',
2726
+ item.nodeId,
2727
+ identity(successorStaged, { deep: false }),
2728
+ );
2729
+ return true;
2730
+ }
2731
+
2732
+ function extractRollbackMovedNode(plan, item, context) {
2733
+ const receipt = readReceipt(plan, 'moves', item.nodeId);
2734
+ if (!receipt) return false;
2735
+ const staged = stagedPath(plan, 'moves', item.nodeId);
2736
+ const target = absolute(plan, item.to);
2737
+ const source = absolute(plan, item.from);
2738
+ if (exists(staged)) {
2739
+ if (ownedIdentity(staged, receipt)) return false;
2740
+ reclassifyUnexpectedMoveStage(plan, item, staged, receipt);
2741
+ }
2742
+ const crossedLocation = ownedIdentity(target, receipt)
2743
+ ? target
2744
+ : context.targetCarriedLocation(item);
2745
+ if (crossedLocation) {
2746
+ ensureDirectory(plan, path.dirname(staged));
2747
+ exclusiveRename(
2748
+ plan,
2749
+ crossedLocation,
2750
+ staged,
2751
+ `rollback-move-extract-${item.nodeId}`,
2752
+ receipt,
2753
+ );
2754
+ if (ownedIdentity(staged, receipt)) return false;
2755
+ reclassifyUnexpectedMoveStage(plan, item, staged, receipt);
2756
+ }
2757
+ // The official inode may already have returned to its old path. Anything
2758
+ // now at the stale target is then an independent local node, not a
2759
+ // successor of this UUID, and must remain for ordinary tree observation.
2760
+ if (ownedIdentity(source, receipt)) return false;
2761
+ // The original receipt-owned node may itself have been moved to a third
2762
+ // local path. That is local overlay, and it proves any unrelated value at
2763
+ // the stale target cannot be this UUID's successor.
2764
+ if (context.ownedLocation(item)) return false;
2765
+
2766
+ // The receipt-owned node crossed and was then replaced at its new path.
2767
+ // That third value is local work whose UUID ownership still needs
2768
+ // classification. Move it to operation-owned staging before extracting any
2769
+ // ancestor; restore/capture later only if its kind and receipt history prove
2770
+ // it is the original UUID's successor.
2771
+ const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
2772
+ let successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
2773
+ if (exists(successorStaged)) {
2774
+ if (!successorReceipt) {
2775
+ successorReceipt = writeReceipt(
2776
+ plan,
2777
+ 'move-successor',
2778
+ item.nodeId,
2779
+ identity(successorStaged, { deep: false }),
2780
+ );
2781
+ }
2782
+ if (!ownedIdentity(successorStaged, successorReceipt)) {
2783
+ throw new Error(`workspace tree move successor changed for ${item.nodeId}`);
2784
+ }
2785
+ return true;
2786
+ }
2787
+ if (successorReceipt && ownedIdentity(source, successorReceipt)) return true;
2788
+ if (successorReceipt) return false;
2789
+ if (!exists(target)) return false;
2790
+ ensureDirectory(plan, path.dirname(successorStaged));
2791
+ exclusiveRename(
2792
+ plan,
2793
+ target,
2794
+ successorStaged,
2795
+ `rollback-successor-stage-${item.nodeId}`,
2796
+ );
2797
+ writeReceipt(
2798
+ plan,
2799
+ 'move-successor',
2800
+ item.nodeId,
2801
+ identity(successorStaged, { deep: false }),
2802
+ );
2803
+ return true;
2804
+ }
2805
+
2806
+ function sameMoveSuccessorKind(item, receipt, held) {
2807
+ const expectedKind = item.kind || item.identity?.kind;
2808
+ if (!receipt || receipt.kind !== expectedKind) return false;
2809
+ if (expectedKind !== 'symlink') return true;
2810
+ try {
2811
+ return fs.readlinkSync(held) === item.symlinkTarget;
2812
+ } catch {
2813
+ return false;
2814
+ }
2815
+ }
2816
+
2817
+ function restoreRollbackMovedNode(plan, item, context) {
2818
+ const receipt = readReceipt(plan, 'moves', item.nodeId);
2819
+ const staged = stagedPath(plan, 'moves', item.nodeId);
2820
+ const source = context.sourcePath(item);
2821
+ const token = crypto.createHash('sha256')
2822
+ .update(`${plan.snapshotChecksum}\0rollback-move\0${item.nodeId}`)
2823
+ .digest('hex')
2824
+ .slice(0, 12);
2825
+ const sourceParentIdentity = context.sourceParentIdentity(item);
2826
+ let officialAtSource = false;
2827
+ if (
2828
+ sourceParentIdentity
2829
+ && ownedIdentity(path.dirname(source), sourceParentIdentity)
2830
+ ) {
2831
+ try {
2832
+ officialAtSource = installOwnedPath(
2833
+ plan,
2834
+ staged,
2835
+ source,
2836
+ receipt,
2837
+ `${token}-official`,
2838
+ { targetParentIdentity: sourceParentIdentity },
2839
+ );
2840
+ } catch (error) {
2841
+ if (!error?.duplicateSuccessor || error.duplicateSuccessor.path !== source) {
2842
+ throw error;
2843
+ }
2844
+ writeReceipt(
2845
+ plan,
2846
+ 'move-successor',
2847
+ item.nodeId,
2848
+ error.duplicateSuccessor.identity,
2849
+ );
2850
+ return true;
2851
+ }
2852
+ }
2853
+
2854
+ const successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
2855
+ const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
2856
+ const successorHeld = ownedIdentity(successorStaged, successorReceipt)
2857
+ ? successorStaged
2858
+ : ownedIdentity(source, successorReceipt)
2859
+ ? source
2860
+ : null;
2861
+ if (!successorHeld) return false;
2862
+
2863
+ if (!officialAtSource && context.ownedLocation(item)) {
2864
+ // The original receipt inode survived at another local path while a
2865
+ // checked source raced into successor staging. Put that independent value
2866
+ // back at its stale-target address (or retain it beside the old source if
2867
+ // its parent disappeared); it cannot inherit the original UUID.
2868
+ const independentTarget = absolute(plan, item.to);
2869
+ if (exists(path.dirname(independentTarget))) {
2870
+ installOwnedPath(
2871
+ plan,
2872
+ successorHeld,
2873
+ independentTarget,
2874
+ successorReceipt,
2875
+ `${token}-independent-target`,
2876
+ );
2877
+ } else {
2878
+ moveHeldAside(
2879
+ plan,
2880
+ successorHeld,
2881
+ source,
2882
+ `${token}-independent-target`,
2883
+ );
2884
+ }
2885
+ return false;
2886
+ }
2887
+
2888
+ if (
2889
+ !officialAtSource
2890
+ && sameMoveSuccessorKind(item, successorReceipt, successorHeld)
2891
+ ) {
2892
+ // The true same-kind successor wins the UUID path. An independently
2893
+ // recreated old source is moved aside and will receive a fresh UUID from
2894
+ // the observer; it must never steal the existing document channel.
2895
+ return installOwnedPath(
2896
+ plan,
2897
+ successorHeld,
2898
+ source,
2899
+ successorReceipt,
2900
+ `${token}-successor`,
2901
+ );
2902
+ }
2903
+
2904
+ // A kind-changing replacement cannot inherit the original UUID. Nor can a
2905
+ // stale target after the receipt-owned node already returned to its source.
2906
+ // Preserve either as independent local structure at a deterministic
2907
+ // conflict path. The observer will trash the absent old UUID when needed
2908
+ // and birth the independent node separately.
2909
+ if (!officialAtSource && exists(source) && source !== successorHeld) {
2910
+ moveAside(plan, source, `${token}-independent-source`);
2911
+ }
2912
+ moveHeldAside(
2913
+ plan,
2914
+ successorHeld,
2915
+ source,
2916
+ `${token}-independent-target`,
2917
+ );
2918
+ return false;
2919
+ }
2920
+
2921
+ function rollbackRemovedNode(plan, item, outputOptions = {}) {
2922
+ const receipt = readReceipt(plan, 'removed', item.nodeId);
2923
+ const staged = stagedPath(plan, 'removed', item.nodeId);
2924
+ if (!ownedIdentity(staged, receipt)) return;
2925
+ mergeRetained(
2926
+ plan,
2927
+ item,
2928
+ staged,
2929
+ absolute(plan, item.from),
2930
+ crypto.createHash('sha256')
2931
+ .update(`${plan.snapshotChecksum}\0rollback-remove\0${item.nodeId}`)
2932
+ .digest('hex')
2933
+ .slice(0, 12),
2934
+ {
2935
+ ...outputOptions,
2936
+ lane: 'rollback',
2937
+ provenance: 'carried-source',
2938
+ },
2939
+ );
2940
+ }
2941
+
2942
+ function structuralActions(plan, reverse = false) {
2943
+ const actions = [
2944
+ ...plan.creates
2945
+ .filter((candidate) => candidate.kind === 'folder')
2946
+ .map((item) => ({ type: 'create', item, target: item.to })),
2947
+ ...plan.moves.map((item) => ({ type: 'move', item, target: item.to })),
2948
+ ].sort((left, right) => (
2949
+ left.target.split('/').length - right.target.split('/').length
2950
+ || left.target.localeCompare(right.target)
2951
+ || left.item.nodeId.localeCompare(right.item.nodeId)
2952
+ ));
2953
+ return reverse ? actions.reverse() : actions;
2954
+ }
2955
+
2956
+ function sourceRestorations(plan) {
2957
+ return [
2958
+ ...plan.removals.map((item) => ({ type: 'remove', item, source: item.from })),
2959
+ ...plan.moves.map((item) => ({ type: 'move', item, source: item.from })),
2960
+ ].sort((left, right) => (
2961
+ left.source.split('/').length - right.source.split('/').length
2962
+ || left.source.localeCompare(right.source)
2963
+ || left.item.nodeId.localeCompare(right.item.nodeId)
2964
+ ));
2965
+ }
2966
+
2967
+ /**
2968
+ * A newer permission epoch may supersede a snapshot after the older plan has
2969
+ * staged bytes but before it earned a database projection. Restore the exact
2970
+ * prior floor from operation receipts, preserve any newer writer, then remove
2971
+ * the obsolete plan. No stale authority is ever projected merely to make
2972
+ * recovery convenient.
2973
+ */
2974
+ function supersedeUncommitted(held, database = openLocalDb(), options = {}) {
2975
+ let current = get(held.resourceId, held.snapshotVersion, database);
2976
+ if (!current) return true;
2977
+ if (current.state === 'committed') return false;
2978
+ let plan = current.plan;
2979
+ const outputOptions = {
2980
+ durable: true,
2981
+ persistPlan(nextPlan) {
2982
+ current = updateUncommittedPlan(current, nextPlan, database);
2983
+ plan = current.plan;
2984
+ },
2985
+ afterOutputReceipt: options.testHooks?.afterRetainedOutputReceipt,
2986
+ afterOutputMove: options.testHooks?.afterRetainedOutputMove,
2987
+ };
2988
+ const moveContext = moveRecoveryContext(plan, {
2989
+ onVisit: options.testHooks?.onRecoveryIndexVisit,
2990
+ });
2991
+ const creates = [...plan.creates].sort((left, right) => (
2992
+ right.to.split('/').length - left.to.split('/').length
2993
+ ));
2994
+ const moveSuccessors = new Set();
2995
+ // Leaves cross after every structural action, so they retire first. Folders
2996
+ // and moves share the after-tree dependency graph. Reverse that one graph
2997
+ // deepest-first so every created or moved descendant leaves before its
2998
+ // parent, then rebuild the before tree parent-first from the same receipts.
2999
+ // This remains valid when both a parent and descendant moved independently.
3000
+ for (const action of creates.filter((candidate) => candidate.kind !== 'folder')) {
3001
+ rollbackCreatedNode(plan, action, options.testHooks, outputOptions);
3002
+ }
3003
+ for (const action of structuralActions(plan, true)) {
3004
+ if (action.type === 'create') {
3005
+ rollbackCreatedNode(plan, action.item, options.testHooks, outputOptions);
3006
+ }
3007
+ else extractRollbackMovedNode(plan, action.item, moveContext);
3008
+ }
3009
+ if (options.testHooks?.crashAfterRollbackExtraction === true) {
3010
+ throw new Error('simulated workspace tree crash after rollback extraction');
3011
+ }
3012
+ let restorationCount = 0;
3013
+ for (const action of sourceRestorations(plan)) {
3014
+ if (action.type === 'move') {
3015
+ if (restoreRollbackMovedNode(plan, action.item, moveContext)) {
3016
+ moveSuccessors.add(action.item.nodeId);
3017
+ }
3018
+ }
3019
+ else rollbackRemovedNode(plan, action.item, outputOptions);
3020
+ restorationCount += 1;
3021
+ if (
3022
+ restorationCount === Number(
3023
+ options.testHooks?.crashAfterRollbackRestorationCount,
3024
+ )
3025
+ ) {
3026
+ throw new Error('simulated workspace tree crash during rollback restoration');
3027
+ }
3028
+ }
3029
+ if (options.testHooks?.crashAfterRollbackRestore === true) {
3030
+ throw new Error('simulated workspace tree crash after rollback restore');
3031
+ }
3032
+ const capturedBindings = new Map();
3033
+ for (const item of plan.moves) {
3034
+ const receipt = readReceipt(plan, 'moves', item.nodeId);
3035
+ const location = moveContext.ownedLocation(item);
3036
+ if (receipt && location && ownedIdentity(location, receipt)) {
3037
+ capturedBindings.set(item.nodeId, {
3038
+ nodeId: item.nodeId,
3039
+ identity: receipt,
3040
+ });
3041
+ }
3042
+ if (!moveSuccessors.has(item.nodeId)) continue;
3043
+ const successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
3044
+ const source = moveContext.sourcePath(item);
3045
+ if (
3046
+ successorReceipt
3047
+ && source
3048
+ && ownedIdentity(source, successorReceipt)
3049
+ ) {
3050
+ capturedBindings.set(item.nodeId, {
3051
+ nodeId: item.nodeId,
3052
+ identity: successorReceipt,
3053
+ });
3054
+ }
3055
+ }
3056
+ if (capturedBindings.size) {
3057
+ const values = Array.from(capturedBindings.values());
3058
+ const now = new Date().toISOString();
3059
+ database.transaction(() => {
3060
+ for (const binding of values) {
3061
+ database.prepare(`
3062
+ UPDATE workspace_nodes
3063
+ SET device_id = ?, inode = ?, missing_at = NULL, updated_at = ?
3064
+ WHERE workspace_id = ? AND id = ?
3065
+ `).run(
3066
+ String(binding.identity.dev),
3067
+ String(binding.identity.ino),
3068
+ now,
3069
+ plan.resourceId,
3070
+ binding.nodeId,
3071
+ );
3072
+ }
3073
+ })();
3074
+ require('../state/docs').captureDocsByNodeIds(
3075
+ new Set(values.map((binding) => binding.nodeId)),
3076
+ { database },
3077
+ );
3078
+ if (typeof options.captureSuccessors === 'function') {
3079
+ options.captureSuccessors(values, plan);
3080
+ }
3081
+ }
3082
+ if (options.testHooks?.crashAfterRollbackSuccessorCapture === true) {
3083
+ throw new Error('simulated workspace tree crash after rollback successor capture');
3084
+ }
3085
+ if (
3086
+ Array.isArray(plan.retainedOutputs)
3087
+ && plan.retainedOutputs.length
3088
+ && typeof options.captureRetainedOutputs === 'function'
3089
+ ) {
3090
+ options.captureRetainedOutputs(plan.retainedOutputs, plan);
3091
+ }
3092
+ cleanupStagingRoot(plan);
3093
+ const result = database.prepare(`
3094
+ DELETE FROM workspace_tree_materializations
3095
+ WHERE resource_id = ?
3096
+ AND snapshot_version = ?
3097
+ AND materialization_state != 'committed'
3098
+ `).run(current.resourceId, current.snapshotVersion);
3099
+ return result.changes === 1;
3100
+ }
3101
+
3102
+ function retireWithIncrementalCapture(plan, items, options, retire) {
3103
+ const retained = [];
3104
+ for (const item of items) {
3105
+ const result = retire(item);
3106
+ if (!result.retained) continue;
3107
+ retained.push(result);
3108
+ if (typeof options.retain === 'function') {
3109
+ options.retain([result], plan);
3110
+ }
3111
+ if (typeof options.afterRetain === 'function') {
3112
+ options.afterRetain([result], plan);
3113
+ }
3114
+ }
3115
+ return retained;
3116
+ }
3117
+
3118
+ function finishOperation(held, database = openLocalDb(), options = {}) {
3119
+ let current = getOperation(held.resourceId, held.mutationId, database);
3120
+ if (!current) return;
3121
+ if (current.state !== 'committed') {
3122
+ throw new Error(`workspace tree operation ${held.mutationId} has not committed`);
3123
+ }
3124
+ const retained = retireWithIncrementalCapture(
3125
+ current.plan,
3126
+ current.plan.actions.filter((action) => action.type === 'remove'),
3127
+ options,
3128
+ (action) => retireRemoval(current.plan, action, {
3129
+ durable: true,
3130
+ persistPlan(plan) {
3131
+ current = updateCommittedPlan(current, plan, database);
3132
+ },
3133
+ afterOutputReceipt: options.afterOutputReceipt,
3134
+ afterOutputMove: options.afterOutputMove,
3135
+ }),
3136
+ );
3137
+ cleanupStagingRoot(current.plan);
3138
+ database.transaction(() => {
3139
+ const deleted = database.prepare(`
3140
+ DELETE FROM workspace_tree_operation_materializations
3141
+ WHERE resource_id = ? AND mutation_id = ?
3142
+ AND materialization_state = 'committed'
3143
+ `).run(held.resourceId, held.mutationId);
3144
+ if (deleted.changes !== 1) {
3145
+ throw new Error(
3146
+ `workspace tree operation ${held.mutationId} lost its control record`,
3147
+ );
3148
+ }
3149
+ if (typeof options.finalizeControlState === 'function') {
3150
+ options.finalizeControlState(current.plan);
3151
+ }
3152
+ })();
3153
+ return { retained };
3154
+ }
3155
+
3156
+ function stagedPath(plan, lane, nodeId) {
3157
+ return path.join(stagingRoot(plan), lane, nodeId);
3158
+ }
3159
+
3160
+ function absolute(plan, relative) {
3161
+ return path.join(plan.rootPath, ...String(relative || '').split('/').filter(Boolean));
3162
+ }
3163
+
3164
+ function pathInside(candidate, root) {
3165
+ const held = path.resolve(candidate);
3166
+ const boundary = path.resolve(root);
3167
+ return held === boundary || held.startsWith(`${boundary}${path.sep}`);
3168
+ }
3169
+
3170
+ function approvedRenameRoot(plan, candidate) {
3171
+ const roots = [
3172
+ plan.rootPath,
3173
+ stagingRoot(plan),
3174
+ ...(Array.isArray(plan.controlRoots) ? plan.controlRoots : []),
3175
+ ].map((root) => path.resolve(root));
3176
+ return roots
3177
+ .filter((root) => pathInside(candidate, root))
3178
+ .sort((left, right) => right.length - left.length)[0] || null;
3179
+ }
3180
+
3181
+ function exclusiveRename(
3182
+ plan,
3183
+ source,
3184
+ target,
3185
+ operation,
3186
+ expectedSource = null,
3187
+ options = {},
3188
+ ) {
3189
+ const canonicalEntry = (candidate) => path.join(
3190
+ fs.realpathSync(path.dirname(candidate)),
3191
+ path.basename(candidate),
3192
+ );
3193
+ const sourceRoleRoot = approvedRenameRoot(plan, source);
3194
+ const targetRoleRoot = approvedRenameRoot(plan, target);
3195
+ if (!sourceRoleRoot || !targetRoleRoot) {
3196
+ throw parentRace(
3197
+ `workspace tree rename has an unapproved endpoint: ${source} -> ${target}`,
3198
+ );
3199
+ }
3200
+ const sourceCanonical = canonicalEntry(source);
3201
+ const targetCanonical = canonicalEntry(target);
3202
+ const canonicalRoleRoot = (root) => (
3203
+ exists(root)
3204
+ ? fs.realpathSync(root)
3205
+ : path.join(fs.realpathSync(path.dirname(root)), path.basename(root))
3206
+ );
3207
+ if (
3208
+ !pathInside(sourceCanonical, canonicalRoleRoot(sourceRoleRoot))
3209
+ || !pathInside(targetCanonical, canonicalRoleRoot(targetRoleRoot))
3210
+ ) {
3211
+ throw parentRace(
3212
+ `workspace tree rename endpoint escaped its physical owner: ${source} -> ${target}`,
3213
+ );
3214
+ }
3215
+ let boundary = path.dirname(sourceCanonical);
3216
+ const targetParent = path.dirname(targetCanonical);
3217
+ while (
3218
+ targetParent !== boundary
3219
+ && !targetParent.startsWith(`${boundary}${path.sep}`)
3220
+ ) {
3221
+ const parent = path.dirname(boundary);
3222
+ if (parent === boundary) {
3223
+ throw new Error(
3224
+ `workspace tree rename has no safe shared boundary: ${source} -> ${target}`,
3225
+ );
3226
+ }
3227
+ boundary = parent;
3228
+ }
3229
+ const boundaryStat = fs.lstatSync(boundary, { bigint: true });
3230
+ if (!boundaryStat.isDirectory()) {
3231
+ throw new Error(`workspace tree rename boundary is not a directory: ${boundary}`);
3232
+ }
3233
+ const relative = (candidate) => {
3234
+ const value = path.relative(boundary, candidate);
3235
+ if (!value || value.startsWith('..') || path.isAbsolute(value)) {
3236
+ throw new Error(`workspace tree rename escapes its boundary: ${candidate}`);
3237
+ }
3238
+ return value.split(path.sep).join('/');
3239
+ };
3240
+ const sourceRelative = relative(sourceCanonical);
3241
+ const targetRelative = relative(targetCanonical);
3242
+ const expected = expectedSource || identity(sourceCanonical, { deep: false });
3243
+ if (!expected?.kind || !expected?.dev || !expected?.ino) {
3244
+ throw new Error(`workspace tree rename has no source identity: ${source}`);
3245
+ }
3246
+ const sourceParentIdentity = options.sourceParentIdentity
3247
+ || identity(path.dirname(sourceCanonical), { deep: false });
3248
+ const targetParentIdentity = options.targetParentIdentity
3249
+ || identity(path.dirname(targetCanonical), { deep: false });
3250
+ if (
3251
+ sourceParentIdentity.kind !== 'folder'
3252
+ || targetParentIdentity.kind !== 'folder'
3253
+ ) {
3254
+ throw new Error(`workspace tree rename has a non-folder parent: ${source} -> ${target}`);
3255
+ }
3256
+ const renameReceiptId = crypto.createHash('sha256')
3257
+ .update(`${operation}\0${path.resolve(source)}\0${path.resolve(target)}`)
3258
+ .digest('hex');
3259
+ writeReceipt(plan, 'rename', renameReceiptId, {
3260
+ source: path.resolve(source),
3261
+ target: path.resolve(target),
3262
+ sourceIdentity: expected,
3263
+ sourceParentIdentity,
3264
+ targetParentIdentity,
3265
+ });
3266
+ if (ownedIdentity(targetCanonical, expected)) {
3267
+ return { status: 'applied', recovered: true };
3268
+ }
3269
+ if (!ownedIdentity(sourceCanonical, expected)) {
3270
+ const result = {
3271
+ status: 'changed',
3272
+ reason: 'source changed before exclusive move',
3273
+ };
3274
+ if (options.allowChanged === true) return result;
3275
+ throw new Error(
3276
+ `workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
3277
+ );
3278
+ }
3279
+ const id = crypto.createHash('sha256')
3280
+ .update(
3281
+ `${plan.contract || CONTRACT}\0${plan.resourceId}\0`
3282
+ + `${plan.snapshotVersion ?? plan.version}\0`
3283
+ + `${plan.snapshotChecksum || plan.mutationId}\0`
3284
+ + `${operation}\0${sourceRelative}\0${targetRelative}`,
3285
+ )
3286
+ .digest('hex');
3287
+ const result = nativeFileTransaction.moveNoReplace({
3288
+ root: boundary,
3289
+ rootDev: boundaryStat.dev.toString(),
3290
+ rootIno: boundaryStat.ino.toString(),
3291
+ source: sourceRelative,
3292
+ sourceIdentity: {
3293
+ kind: expected.kind,
3294
+ dev: expected.dev,
3295
+ ino: expected.ino,
3296
+ },
3297
+ sourceParentIdentity,
3298
+ targetParentIdentity,
3299
+ path: targetRelative,
3300
+ id,
3301
+ operation,
3302
+ }, options.testHooks || {});
3303
+ if (result.status !== 'applied') {
3304
+ if (options.allowChanged === true) return result;
3305
+ if (String(result.reason || '').includes('parent changed')) {
3306
+ throw parentRace(
3307
+ `workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
3308
+ );
3309
+ }
3310
+ throw new Error(
3311
+ `workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
3312
+ );
3313
+ }
3314
+ return result;
3315
+ }
3316
+
3317
+ function syncDirectory(directory) {
3318
+ const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
3319
+ try {
3320
+ fs.fsyncSync(descriptor);
3321
+ } finally {
3322
+ fs.closeSync(descriptor);
3323
+ }
3324
+ }
3325
+
3326
+ function syncDirectoryChain(plan, directory) {
3327
+ const boundary = path.dirname(plan.rootPath);
3328
+ let current = directory;
3329
+ for (;;) {
3330
+ syncDirectory(current);
3331
+ if (current === boundary) break;
3332
+ const parent = path.dirname(current);
3333
+ if (
3334
+ parent === current
3335
+ || (current !== plan.rootPath
3336
+ && !current.startsWith(`${plan.rootPath}${path.sep}`)
3337
+ && !current.startsWith(`${boundary}${path.sep}`))
3338
+ ) {
3339
+ throw new Error(`workspace tree directory escaped its root: ${directory}`);
3340
+ }
3341
+ current = parent;
3342
+ }
3343
+ }
3344
+
3345
+ function ensureDirectory(plan, directory) {
3346
+ ensureDurableDirectory(directory);
3347
+ syncDirectoryChain(plan, directory);
3348
+ }
3349
+
3350
+ function requireExistingDirectory(plan, directory, nodeId) {
3351
+ if (!exists(directory) || !fs.lstatSync(directory).isDirectory()) {
3352
+ throw new Error(`workspace tree target parent is not installed for ${nodeId}`);
3353
+ }
3354
+ syncDirectoryChain(plan, directory);
3355
+ }
3356
+
3357
+ function requireRoot(plan, database = openLocalDb()) {
3358
+ let observed;
3359
+ try {
3360
+ observed = identity(plan.rootPath);
3361
+ } catch (error) {
3362
+ if (!isPathAbsentError(error)) throw error;
3363
+ throw Object.assign(
3364
+ retainedRetry(
3365
+ `workspace tree root is missing: ${plan.rootPath}`,
3366
+ 'workspace-root-missing',
3367
+ ),
3368
+ { paths: [plan.rootPath] },
3369
+ );
3370
+ }
3371
+ const bound = database.prepare(`
3372
+ SELECT device_id, inode
3373
+ FROM workspace_nodes
3374
+ WHERE workspace_id = ? AND id = ?
3375
+ `).get(plan.resourceId, plan.resourceId);
3376
+ const bindingMayBeBornByThisPlan = (
3377
+ plan.initial === true
3378
+ && (!bound || (!bound.device_id && !bound.inode))
3379
+ );
3380
+ if (
3381
+ observed.kind !== 'folder'
3382
+ || observed.dev !== plan.rootIdentity.dev
3383
+ || observed.ino !== plan.rootIdentity.ino
3384
+ || (
3385
+ !bindingMayBeBornByThisPlan
3386
+ && (
3387
+ !bound?.device_id
3388
+ || !bound?.inode
3389
+ || String(bound.device_id) !== String(plan.rootIdentity.dev)
3390
+ || String(bound.inode) !== String(plan.rootIdentity.ino)
3391
+ )
3392
+ )
3393
+ ) {
3394
+ throw Object.assign(
3395
+ retainedRetry(
3396
+ `workspace tree root changed during materialization: ${plan.rootPath}`,
3397
+ 'workspace-root-replaced',
3398
+ ),
3399
+ { paths: [plan.rootPath] },
3400
+ );
3401
+ }
3402
+ }
3403
+
3404
+ function stageOne(plan, item, lane, testHooks = {}) {
3405
+ const source = absolute(plan, item.from);
3406
+ const staged = stagedPath(plan, lane, item.nodeId);
3407
+ const target = item.to == null ? null : absolute(plan, item.to);
3408
+ const receipt = readReceipt(plan, lane, item.nodeId);
3409
+ if (target && receipt && ownedIdentity(target, receipt)) return;
3410
+ stageOwnedPath({
3411
+ plan,
3412
+ source,
3413
+ staged,
3414
+ nodeId: item.nodeId,
3415
+ receiptLane: lane,
3416
+ expected: item.identity,
3417
+ sourceParentIdentity: plannedParentIdentity(
3418
+ plan,
3419
+ item.sourceParentNodeId,
3420
+ 'source',
3421
+ ),
3422
+ operation: `snapshot-stage-${lane}-${item.nodeId}`,
3423
+ renameTestHooks: testHooks.snapshotStageRename || {},
3424
+ });
3425
+ }
3426
+
3427
+ function installMove(plan, item, testHooks = {}) {
3428
+ const staged = stagedPath(plan, 'moves', item.nodeId);
3429
+ const target = absolute(plan, item.to);
3430
+ const receipt = readReceipt(plan, 'moves', item.nodeId);
3431
+ if (!receipt) {
3432
+ throw new Error(`workspace tree move has no operation receipt for ${item.nodeId}`);
3433
+ }
3434
+ if (!exists(staged)) {
3435
+ if (
3436
+ readReceipt(plan, 'move-installing', item.nodeId)
3437
+ && ownedIdentity(target, receipt)
3438
+ ) return;
3439
+ throw new Error(`workspace tree staged move disappeared for ${item.nodeId}`);
3440
+ }
3441
+ if (!ownedIdentity(staged, receipt)) {
3442
+ throw new Error(`workspace tree staged move changed for ${item.nodeId}`);
3443
+ }
3444
+ writeReceipt(plan, 'move-installing', item.nodeId, receipt);
3445
+ if (!installOwnedPath(
3446
+ plan,
3447
+ staged,
3448
+ target,
3449
+ receipt,
3450
+ `snapshot-move-install-${item.nodeId}`,
3451
+ {
3452
+ targetParentIdentity: plannedParentIdentity(
3453
+ plan,
3454
+ item.targetParentNodeId,
3455
+ 'target',
3456
+ ),
3457
+ testHooks: testHooks.snapshotInstallRename || {},
3458
+ },
3459
+ )) {
3460
+ throw new Error(`workspace tree move lost its receipt for ${item.nodeId}`);
3461
+ }
3462
+ }
3463
+
3464
+ function createProjectedNode(plan, item) {
3465
+ const target = absolute(plan, item.to);
3466
+ const prepared = stagedPath(plan, 'creates', item.nodeId);
3467
+ let receipt = readReceipt(plan, 'creates', item.nodeId);
3468
+ if (!receipt) {
3469
+ if (!exists(prepared)) {
3470
+ createOperationTargetAt(prepared, {
3471
+ nodeId: item.nodeId,
3472
+ kind: item.kind,
3473
+ symlinkTarget: item.symlinkTarget,
3474
+ });
3475
+ }
3476
+ receipt = writeReceipt(
3477
+ plan,
3478
+ 'creates',
3479
+ item.nodeId,
3480
+ identity(prepared, { deep: false }),
3481
+ );
3482
+ }
3483
+ if (exists(prepared)) {
3484
+ if (!ownedIdentity(prepared, receipt)) {
3485
+ throw new Error(`workspace tree prepared create changed for ${item.nodeId}`);
3486
+ }
3487
+ writeReceipt(plan, 'create-installing', item.nodeId, receipt);
3488
+ if (!installOwnedPath(
3489
+ plan,
3490
+ prepared,
3491
+ target,
3492
+ receipt,
3493
+ `snapshot-create-install-${item.nodeId}`,
3494
+ {
3495
+ targetParentIdentity: plannedParentIdentity(
3496
+ plan,
3497
+ item.targetParentNodeId,
3498
+ 'target',
3499
+ ),
3500
+ },
3501
+ )) {
3502
+ throw new Error(`workspace tree create lost its receipt for ${item.nodeId}`);
3503
+ }
3504
+ return true;
3505
+ }
3506
+ if (
3507
+ !readReceipt(plan, 'create-installing', item.nodeId)
3508
+ || !ownedIdentity(target, receipt)
3509
+ ) {
3510
+ throw new Error(`workspace tree create has no crossing receipt for ${item.nodeId}`);
3511
+ }
3512
+ return false;
3513
+ }
3514
+
3515
+ function verify(plan, database = openLocalDb()) {
3516
+ requireRoot(plan, database);
3517
+ const plannedTargets = new Set([
3518
+ ...plan.moves.map((item) => item.to),
3519
+ ...plan.creates.map((item) => item.to),
3520
+ ]);
3521
+ for (const item of plan.moves) {
3522
+ if (!ownedIdentity(
3523
+ absolute(plan, item.to),
3524
+ readReceipt(plan, 'moves', item.nodeId),
3525
+ )) {
3526
+ throw new Error(`workspace tree move did not verify for ${item.nodeId}`);
3527
+ }
3528
+ }
3529
+ for (const item of plan.removals) {
3530
+ const source = absolute(plan, item.from);
3531
+ if (exists(source) && !plannedTargets.has(item.from)) {
3532
+ throw new Error(`workspace tree removal did not verify for ${item.nodeId}`);
3533
+ }
3534
+ if (!ownedIdentity(
3535
+ stagedPath(plan, 'removed', item.nodeId),
3536
+ readReceipt(plan, 'removed', item.nodeId),
3537
+ )) {
3538
+ throw new Error(`workspace tree removal receipt did not verify for ${item.nodeId}`);
3539
+ }
3540
+ }
3541
+ for (const item of plan.creates) {
3542
+ const target = absolute(plan, item.to);
3543
+ if (!ownedIdentity(
3544
+ target,
3545
+ readReceipt(plan, 'creates', item.nodeId),
3546
+ )) {
3547
+ throw new Error(`workspace tree create did not verify for ${item.nodeId}`);
3548
+ }
3549
+ if (item.kind === 'symlink' && fs.readlinkSync(target) !== item.symlinkTarget) {
3550
+ throw new Error(`workspace tree symlink did not verify for ${item.nodeId}`);
3551
+ }
3552
+ }
3553
+ if (!Array.isArray(plan.projectedBindings)) {
3554
+ throw new Error('workspace tree snapshot has no complete projected-node proof');
3555
+ }
3556
+ for (const item of plan.projectedBindings) {
3557
+ const target = absolute(plan, item.to);
3558
+ if (!ownedIdentity(target, item.identity)) {
3559
+ throw new Error(`workspace tree projected node did not verify for ${item.nodeId}`);
3560
+ }
3561
+ if (item.kind === 'symlink' && fs.readlinkSync(target) !== item.symlinkTarget) {
3562
+ throw new Error(`workspace tree projected symlink did not verify for ${item.nodeId}`);
3563
+ }
3564
+ }
3565
+ }
3566
+
3567
+ function cross(held, database = openLocalDb(), testHooks = {}) {
3568
+ if (!held) throw new Error('workspace tree materialization plan is missing');
3569
+ let plan = held.plan;
3570
+ if (held.state === 'committed') return held;
3571
+ if (held.state === 'crossed') {
3572
+ verify(plan, database);
3573
+ return held;
3574
+ }
3575
+ requireRoot(plan, database);
3576
+ const extraction = [
3577
+ ...plan.moves.map((item) => ({ item, lane: 'moves' })),
3578
+ ...plan.removals.map((item) => ({ item, lane: 'removed' })),
3579
+ ].sort((left, right) => (
3580
+ right.item.from.split('/').length - left.item.from.split('/').length
3581
+ ));
3582
+ // Prove the entire source set before the first rename. A changed file or a
3583
+ // new descendant is local work, not permission to delete the new bytes
3584
+ // under an old inode-only receipt.
3585
+ try {
3586
+ const resumingStagedPlan = exists(stagingRoot(plan));
3587
+ for (const { item, lane } of extraction) {
3588
+ const source = absolute(plan, item.from);
3589
+ const staged = stagedPath(plan, lane, item.nodeId);
3590
+ const target = item.to == null ? null : absolute(plan, item.to);
3591
+ const expected = (
3592
+ !resumingStagedPlan
3593
+ && item.preflightIdentity
3594
+ ) || item.identity;
3595
+ if (
3596
+ sameIdentity(source, expected)
3597
+ || exists(staged)
3598
+ || (target && sameIdentity(target, item.identity))
3599
+ ) continue;
3600
+ throw new Error(`workspace tree source content changed before snapshot for ${item.nodeId}`);
3601
+ }
3602
+ } catch (error) {
3603
+ if (!exists(stagingRoot(plan))) {
3604
+ database.prepare(`
3605
+ DELETE FROM workspace_tree_materializations
3606
+ WHERE resource_id = ? AND snapshot_version = ? AND materialization_state = 'prepared'
3607
+ `).run(held.resourceId, held.snapshotVersion);
3608
+ }
3609
+ throw error;
3610
+ }
3611
+ try {
3612
+ for (const { item, lane } of extraction) {
3613
+ stageOne(plan, item, lane, testHooks);
3614
+ }
3615
+ } catch (error) {
3616
+ if (error?.code === 'WORKSPACE_TREE_SOURCE_RACE') {
3617
+ supersedeUncommitted(held, database);
3618
+ }
3619
+ throw error;
3620
+ }
3621
+ if (plan.removals.some((item) => item.preflightIdentity)) {
3622
+ plan = {
3623
+ ...plan,
3624
+ removals: plan.removals.map((item) => ({
3625
+ ...item,
3626
+ identity: identity(stagedPath(plan, 'removed', item.nodeId)),
3627
+ })),
3628
+ };
3629
+ database.prepare(`
3630
+ UPDATE workspace_tree_materializations
3631
+ SET plan_json = ?,
3632
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3633
+ WHERE resource_id = ? AND snapshot_version = ? AND materialization_state = 'prepared'
3634
+ `).run(JSON.stringify(plan), held.resourceId, held.snapshotVersion);
3635
+ held = get(held.resourceId, held.snapshotVersion, database);
3636
+ plan = held.plan;
3637
+ }
3638
+ if (testHooks.crashAfterStage === true) {
3639
+ throw new Error('simulated workspace tree crash after staging');
3640
+ }
3641
+ try {
3642
+ let created = 0;
3643
+ const createOne = (item) => {
3644
+ if (createProjectedNode(plan, item)) created += 1;
3645
+ if (created > 0 && created === Number(testHooks.crashAfterCreates)) {
3646
+ throw new Error('simulated workspace tree crash after create');
3647
+ }
3648
+ };
3649
+ for (const action of structuralActions(plan)) {
3650
+ if (action.type === 'create') createOne(action.item);
3651
+ else installMove(plan, action.item, testHooks);
3652
+ }
3653
+ for (const item of plan.creates.filter((candidate) => candidate.kind !== 'folder')) {
3654
+ createOne(item);
3655
+ }
3656
+ if (typeof testHooks.beforeTreePlanVerify === 'function') {
3657
+ testHooks.beforeTreePlanVerify(plan);
3658
+ }
3659
+ verify(plan, database);
3660
+ } catch (error) {
3661
+ if (
3662
+ error?.code === 'WORKSPACE_TREE_SOURCE_RACE'
3663
+ || error?.code === 'WORKSPACE_TREE_PARENT_RACE'
3664
+ ) {
3665
+ supersedeUncommitted(held, database);
3666
+ }
3667
+ throw error;
3668
+ }
3669
+ database.prepare(`
3670
+ UPDATE workspace_tree_materializations
3671
+ SET materialization_state = 'crossed',
3672
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3673
+ WHERE resource_id = ? AND snapshot_version = ?
3674
+ `).run(held.resourceId, held.snapshotVersion);
3675
+ return get(held.resourceId, held.snapshotVersion, database);
3676
+ }
3677
+
3678
+ function markCommitted(held, database = openLocalDb()) {
3679
+ const result = database.prepare(`
3680
+ UPDATE workspace_tree_materializations
3681
+ SET materialization_state = 'committed',
3682
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
3683
+ WHERE resource_id = ?
3684
+ AND snapshot_version = ?
3685
+ AND materialization_state = 'crossed'
3686
+ `).run(held.resourceId, held.snapshotVersion);
3687
+ if (result.changes !== 1) {
3688
+ const current = get(held.resourceId, held.snapshotVersion, database);
3689
+ if (!current || current.state !== 'committed') {
3690
+ throw new Error(
3691
+ `workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} did not commit`,
3692
+ );
3693
+ }
3694
+ return current;
3695
+ }
3696
+ return get(held.resourceId, held.snapshotVersion, database);
3697
+ }
3698
+
3699
+ function finish(held, database = openLocalDb(), options = {}) {
3700
+ let current = get(held.resourceId, held.snapshotVersion, database);
3701
+ if (!current) return;
3702
+ if (current.state !== 'committed') {
3703
+ throw new Error(
3704
+ `workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} has not committed`,
3705
+ );
3706
+ }
3707
+ const retained = retireWithIncrementalCapture(
3708
+ current.plan,
3709
+ current.plan.removals,
3710
+ options,
3711
+ (item) => retireRemoval(current.plan, item, {
3712
+ durable: true,
3713
+ persistPlan(plan) {
3714
+ current = updateCommittedPlan(current, plan, database);
3715
+ },
3716
+ afterOutputReceipt: options.afterOutputReceipt,
3717
+ afterOutputMove: options.afterOutputMove,
3718
+ }),
3719
+ );
3720
+ cleanupStagingRoot(current.plan);
3721
+ syncDirectory(current.plan.rootPath);
3722
+ syncDirectory(path.dirname(current.plan.rootPath));
3723
+ database.transaction(() => {
3724
+ const result = database.prepare(`
3725
+ DELETE FROM workspace_tree_materializations
3726
+ WHERE resource_id = ? AND snapshot_version = ?
3727
+ AND materialization_state = 'committed'
3728
+ `).run(held.resourceId, held.snapshotVersion);
3729
+ if (result.changes !== 1) {
3730
+ throw new Error(
3731
+ `workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} lost its control record`,
3732
+ );
3733
+ }
3734
+ if (typeof options.finalizeControlState === 'function') {
3735
+ options.finalizeControlState(current.plan);
3736
+ }
3737
+ })();
3738
+ return { retained };
3739
+ }
3740
+
3741
+ module.exports = {
3742
+ appendOperationSuccessor,
3743
+ clearOperationSuccessors,
3744
+ cross,
3745
+ crossOperation,
3746
+ cancelPreparedOperation,
3747
+ finish,
3748
+ finishOperation,
3749
+ get,
3750
+ getOperation,
3751
+ identity,
3752
+ insert,
3753
+ insertOperation,
3754
+ ensureDurableDirectory,
3755
+ locateOperationBindings,
3756
+ list,
3757
+ listOperationSuccessors,
3758
+ listOperations,
3759
+ markCommitted,
3760
+ markOperationCommitted,
3761
+ replacePreparedOperation,
3762
+ updateCommittedPlan,
3763
+ updateUncommittedPlan,
3764
+ removalPrecondition,
3765
+ supersedeUncommitted,
3766
+ };