amalgm 0.1.239 → 0.1.240

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 (86) hide show
  1. package/lib/shared-realtime-tunnel.js +6 -1
  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/state/attachments.js +40 -12
  6. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  7. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  8. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  9. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  10. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  11. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  12. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  13. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  14. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  15. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  16. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  17. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  18. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  19. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  20. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  21. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +8 -4
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  41. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  42. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  43. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  44. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  45. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  46. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  48. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  49. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  50. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
  67. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  68. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  69. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  70. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  71. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  72. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  73. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  74. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  83. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  86. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -3,14 +3,13 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { isPathAbsentError } = require('./tree/path-state');
6
7
  const { openLocalDb } = require('../state/db');
7
8
  const { applyTreeOperation } = require('./tree/reducer');
8
9
  const { can } = require('./tree/access');
9
10
  const { relativeWorkspacePath, shouldEnroll, shouldSync } = require('./tree/inclusion');
10
11
  const {
11
12
  hasAncestorInSet,
12
- materializeOperation,
13
- persistNodes,
14
13
  relativePathForNode,
15
14
  rowToNode,
16
15
  workspaceRow,
@@ -18,13 +17,13 @@ const {
18
17
  const { normalizeTreeOperation } = require('./tree/operations');
19
18
  const { uuidV7 } = require('./tree/identity');
20
19
  const { isGitOwnedPath } = require('./repo-scope');
20
+ const {
21
+ ROOT_HOLD_REASONS,
22
+ inspectWorkspaceRoot,
23
+ rootBindingError,
24
+ } = require('./tree/root-binding');
21
25
 
22
26
  const TREE_RESOURCE_PREFIX = 'workspace-tree:';
23
- // Tree enrollment and disk observation run inside the MCP process. Keep those
24
- // automatic passes bounded so one very large workspace cannot monopolize the
25
- // event loop and make the runtime health endpoint unavailable. Explicit
26
- // workspace operations remain uncapped.
27
- const AUTOMATIC_TREE_NODE_LIMIT = 2_000;
28
27
 
29
28
  function invalid(message, statusCode = 400) {
30
29
  return Object.assign(new Error(message), { statusCode });
@@ -58,6 +57,11 @@ function nodeForPath(absolutePath, options = {}) {
58
57
  const database = options.database || openLocalDb();
59
58
  const workspace = workspaceForPath(absolutePath, database);
60
59
  if (!workspace) return null;
60
+ const proof = inspectWorkspaceRoot(workspace, database);
61
+ if (proof.state !== 'verified') {
62
+ observerRootHold(workspace, proof, database);
63
+ return null;
64
+ }
61
65
  const relativePath = path.resolve(absolutePath) === path.resolve(workspace.path)
62
66
  ? ''
63
67
  : relativeWorkspacePath(workspace.path, path.resolve(absolutePath));
@@ -81,6 +85,12 @@ function absolutePathForNode(nodeId, options = {}) {
81
85
  WHERE workspace_nodes.id = ?
82
86
  `).get(nodeId);
83
87
  if (!row || row.purged_at || row.missing_at) return null;
88
+ const workspace = { id: row.workspace_id, path: row.workspace_path };
89
+ const proof = inspectWorkspaceRoot(workspace, database);
90
+ if (proof.state !== 'verified') {
91
+ observerRootHold(workspace, proof, database);
92
+ return null;
93
+ }
84
94
  return row.relative_path
85
95
  ? path.join(row.workspace_path, ...row.relative_path.split('/'))
86
96
  : row.workspace_path;
@@ -143,6 +153,135 @@ function scanDirectory(rootPath, options = {}) {
143
153
  return entries;
144
154
  }
145
155
 
156
+ function physicalAliasGroups(entries) {
157
+ const byIdentity = new Map();
158
+ for (const entry of entries) {
159
+ if (entry.kind !== 'file') continue;
160
+ const key = `${entry.deviceId}:${entry.inode}`;
161
+ const aliases = byIdentity.get(key) || [];
162
+ aliases.push(entry);
163
+ byIdentity.set(key, aliases);
164
+ }
165
+ return Array.from(byIdentity.values()).filter((aliases) => aliases.length > 1);
166
+ }
167
+
168
+ function scanWorkspacePhysicalAliases(identifier, options = {}) {
169
+ const database = options.database || openLocalDb();
170
+ const workspace = workspaceRow(identifier, database);
171
+ if (!workspace) throw invalid('workspace not found', 404);
172
+ const rootPath = path.resolve(workspace.path || '');
173
+ const rootStats = fs.statSync(rootPath);
174
+ if (!rootStats.isDirectory()) {
175
+ throw invalid('workspace path is not a folder', 409);
176
+ }
177
+ return physicalAliasGroups(scanDirectory(rootPath)).map((aliases) => ({
178
+ deviceId: aliases[0].deviceId,
179
+ inode: aliases[0].inode,
180
+ paths: aliases.map((entry) => entry.relativePath),
181
+ }));
182
+ }
183
+
184
+ function observerHoldsExceptRoot(workspace, database) {
185
+ const observationHolds = require('./tree/observation-holds');
186
+ return observationHolds.list(workspace.id, database)
187
+ .filter((hold) => (
188
+ hold.ownerKind === 'observer'
189
+ && hold.ownerId === 'workspace-tree-observer'
190
+ && !ROOT_HOLD_REASONS.has(hold.reason)
191
+ ))
192
+ .map((hold) => ({
193
+ reason: hold.reason,
194
+ nodeId: hold.nodeId,
195
+ operation: hold.operationKind ? { kind: hold.operationKind } : null,
196
+ paths: hold.paths,
197
+ }));
198
+ }
199
+
200
+ function observerRootHold(workspace, proof, database) {
201
+ const existing = observerHoldsExceptRoot(workspace, database);
202
+ const hold = {
203
+ reason: proof.reason || 'workspace-root-enrollment-pending',
204
+ nodeId: workspace.id,
205
+ paths: [workspace.path],
206
+ };
207
+ require('./store').commitCompletedTreeObservation(
208
+ workspace.id,
209
+ [...existing, hold],
210
+ { database, source: 'workspace-tree:root-hold' },
211
+ );
212
+ return {
213
+ workspaceId: workspace.id,
214
+ state: 'held',
215
+ operations: [],
216
+ created: 0,
217
+ nodes: [],
218
+ skipped: hold.reason,
219
+ held: [hold],
220
+ };
221
+ }
222
+
223
+ function clearObserverRootHolds(
224
+ workspace,
225
+ database,
226
+ source,
227
+ mutate = null,
228
+ ) {
229
+ require('./store').commitCompletedTreeObservation(
230
+ workspace.id,
231
+ observerHoldsExceptRoot(workspace, database),
232
+ {
233
+ database,
234
+ source,
235
+ ...(typeof mutate === 'function' ? { mutate } : {}),
236
+ },
237
+ );
238
+ }
239
+
240
+ function bindExcludedWorkspaceRoot(workspace, proof, database) {
241
+ const now = new Date().toISOString();
242
+ clearObserverRootHolds(
243
+ workspace,
244
+ database,
245
+ 'workspace-tree:excluded-root-binding',
246
+ () => {
247
+ database.prepare(`
248
+ UPDATE workspace_nodes
249
+ SET name = ?, device_id = ?, inode = ?, missing_at = NULL,
250
+ updated_at = ?
251
+ WHERE workspace_id = ? AND id = ?
252
+ `).run(
253
+ workspace.name,
254
+ proof.actual.deviceId,
255
+ proof.actual.inode,
256
+ now,
257
+ workspace.id,
258
+ workspace.id,
259
+ );
260
+ },
261
+ );
262
+ return {
263
+ workspaceId: workspace.id,
264
+ state: 'excluded',
265
+ created: 0,
266
+ nodes: [],
267
+ skipped: 'git-owned',
268
+ };
269
+ }
270
+
271
+ function verifiedWorkspaceRoot(identifier, options = {}) {
272
+ const database = options.database || openLocalDb();
273
+ const workspace = typeof identifier === 'object'
274
+ ? identifier
275
+ : workspaceRow(identifier, database);
276
+ if (!workspace) throw invalid('workspace not found', 404);
277
+ const proof = inspectWorkspaceRoot(workspace, database);
278
+ if (proof.state !== 'verified') {
279
+ observerRootHold(workspace, proof, database);
280
+ throw rootBindingError(proof);
281
+ }
282
+ return proof;
283
+ }
284
+
146
285
  /**
147
286
  * Enroll current disk state without turning a path into identity. Existing
148
287
  * paths retain their UUID; a same-filesystem rename is recognized by inode;
@@ -154,10 +293,52 @@ function enrollWorkspace(identifier, options = {}) {
154
293
  const workspace = workspaceRow(identifier, database);
155
294
  if (!workspace) throw invalid('workspace not found', 404);
156
295
  const rootPath = path.resolve(workspace.path || '');
157
- const rootStats = fs.statSync(rootPath);
158
- if (!rootStats.isDirectory()) throw invalid('workspace path is not a folder', 409);
296
+ const rootProof = inspectWorkspaceRoot(workspace, database);
297
+ if (rootProof.state === 'held' || !rootProof.actual && rootProof.state === 'unbound') {
298
+ return observerRootHold(
299
+ workspace,
300
+ rootProof.state === 'held'
301
+ ? rootProof
302
+ : { ...rootProof, reason: 'workspace-root-missing' },
303
+ database,
304
+ );
305
+ }
159
306
  if (isGitOwnedPath(rootPath)) {
160
- return { workspaceId: workspace.id, created: 0, nodes: [], skipped: 'git-owned' };
307
+ if (rootProof.state === 'unbound') {
308
+ return bindExcludedWorkspaceRoot(workspace, rootProof, database);
309
+ }
310
+ clearObserverRootHolds(
311
+ workspace,
312
+ database,
313
+ 'workspace-tree:excluded-root-verified',
314
+ );
315
+ return {
316
+ workspaceId: workspace.id,
317
+ state: 'excluded',
318
+ created: 0,
319
+ nodes: [],
320
+ skipped: 'git-owned',
321
+ };
322
+ }
323
+ if (rootProof.state === 'verified') {
324
+ // Enrollment is the one-time act that gives an existing tree its initial
325
+ // UUIDs. Once the root has a physical binding, every later disk change
326
+ // must pass through the journaled observer. Re-running the baseline
327
+ // writer here could otherwise absorb a permission-held change and clear
328
+ // the observer's durable hold without ever issuing its mutation.
329
+ const observed = reconcileWorkspaceTree(workspace.id, {
330
+ database,
331
+ maxNodes: options.maxNodes,
332
+ });
333
+ const operations = observed.operations || [];
334
+ return {
335
+ ...observed,
336
+ created: operations.filter((operation) => operation.kind === 'tree.create').length,
337
+ renamed: operations.filter((operation) => (
338
+ operation.kind === 'tree.rename' || operation.kind === 'tree.move'
339
+ )).length,
340
+ nodes: listWorkspaceNodes(workspace.id, { database }),
341
+ };
161
342
  }
162
343
 
163
344
  const maxNodes = treeNodeLimit(options);
@@ -165,7 +346,11 @@ function enrollWorkspace(identifier, options = {}) {
165
346
  'SELECT count(*) AS count FROM workspace_nodes WHERE workspace_id = ?',
166
347
  ).get(workspace.id).count;
167
348
  if (maxNodes != null && existingNodeCount > maxNodes) {
168
- return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
349
+ return observerRootHold(
350
+ workspace,
351
+ { reason: 'workspace-root-enrollment-pending' },
352
+ database,
353
+ );
169
354
  }
170
355
 
171
356
  let scanned;
@@ -173,10 +358,34 @@ function enrollWorkspace(identifier, options = {}) {
173
358
  scanned = scanDirectory(rootPath, { maxNodes });
174
359
  } catch (error) {
175
360
  if (error?.code === 'WORKSPACE_TREE_NODE_LIMIT') {
176
- return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
361
+ return observerRootHold(
362
+ workspace,
363
+ { reason: 'workspace-root-enrollment-pending' },
364
+ database,
365
+ );
177
366
  }
178
367
  throw error;
179
368
  }
369
+ const aliasGroups = physicalAliasGroups(scanned);
370
+ if (aliasGroups.length) {
371
+ const result = {
372
+ workspaceId: workspace.id,
373
+ state: 'held',
374
+ created: 0,
375
+ nodes: [],
376
+ skipped: 'ambiguous-physical-alias',
377
+ held: aliasGroups.map((aliases) => ({
378
+ reason: 'ambiguous-physical-alias',
379
+ paths: aliases.map((entry) => entry.relativePath),
380
+ })),
381
+ };
382
+ require('./store').commitCompletedTreeObservation(
383
+ workspace.id,
384
+ result.held,
385
+ { database },
386
+ );
387
+ return result;
388
+ }
180
389
  const existing = database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id);
181
390
  // Missing/trash/purged rows are tombstones, not rename candidates. In
182
391
  // particular, a later file whose inode was recycled must receive a fresh
@@ -203,7 +412,13 @@ function enrollWorkspace(identifier, options = {}) {
203
412
  UPDATE workspace_nodes
204
413
  SET name = ?, device_id = ?, inode = ?, missing_at = NULL, updated_at = ?
205
414
  WHERE id = ?
206
- `).run(workspace.name, String(rootStats.dev), String(rootStats.ino), now, workspace.id);
415
+ `).run(
416
+ workspace.name,
417
+ rootProof.actual.deviceId,
418
+ rootProof.actual.inode,
419
+ now,
420
+ workspace.id,
421
+ );
207
422
 
208
423
  for (const entry of scanned) {
209
424
  const parentId = nodeIdByPath.get(entry.parentRelativePath);
@@ -285,14 +500,25 @@ function enrollWorkspace(identifier, options = {}) {
285
500
  console.warn('[WorkspaceTree] live document rebind after scan failed:', error?.message || error);
286
501
  }
287
502
  }
288
- return { workspaceId: workspace.id, created, renamed: renamed.length, nodes: listWorkspaceNodes(workspace.id, { database }) };
503
+ require('./store').commitCompletedTreeObservation(
504
+ workspace.id,
505
+ [],
506
+ { database },
507
+ );
508
+ return {
509
+ workspaceId: workspace.id,
510
+ state: 'ready',
511
+ created,
512
+ renamed: renamed.length,
513
+ nodes: listWorkspaceNodes(workspace.id, { database }),
514
+ };
289
515
  }
290
516
 
291
517
  function lstatOrNull(targetPath) {
292
518
  try {
293
519
  return fs.lstatSync(targetPath);
294
520
  } catch (error) {
295
- if (error?.code === 'ENOENT') return null;
521
+ if (isPathAbsentError(error)) return null;
296
522
  throw error;
297
523
  }
298
524
  }
@@ -329,6 +555,38 @@ function requirePathCapability(pathInput, capability, options = {}) {
329
555
  return { ...found, role };
330
556
  }
331
557
 
558
+ function operationPathForResult(workspace, nodes, nodeId) {
559
+ const relative = relativePathForNode(nodes, nodeId);
560
+ return relative
561
+ ? path.join(workspace.path, ...relative.split('/'))
562
+ : workspace.path;
563
+ }
564
+
565
+ function assertExpectedDiskIdentity(expected) {
566
+ if (!expected) return;
567
+ const stats = lstatOrNull(expected.path);
568
+ const kind = stats?.isSymbolicLink()
569
+ ? 'symlink'
570
+ : stats?.isDirectory()
571
+ ? 'folder'
572
+ : stats
573
+ ? 'file'
574
+ : null;
575
+ if (
576
+ kind !== expected.kind
577
+ || String(stats?.dev ?? '') !== String(expected.deviceId)
578
+ || String(stats?.ino ?? '') !== String(expected.inode)
579
+ ) {
580
+ throw Object.assign(
581
+ new Error(`workspace tree physical binding changed at ${expected.path}`),
582
+ {
583
+ code: 'WORKSPACE_TREE_PHYSICAL_BINDING_CHANGED',
584
+ statusCode: 409,
585
+ },
586
+ );
587
+ }
588
+ }
589
+
332
590
  function commitTreeOperation(input, options = {}) {
333
591
  let operation = normalizeTreeOperation(input);
334
592
  const database = options.database || openLocalDb();
@@ -338,15 +596,22 @@ function commitTreeOperation(input, options = {}) {
338
596
  if (!can(role, capabilityForOperation(operation.kind))) throw invalid('tree operation is forbidden', 403);
339
597
  const workspace = workspaceRow(operation.workspaceId, database);
340
598
  if (!workspace) throw invalid('workspace not found', 404);
341
- const before = new Map(
342
- database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
343
- .map(rowToNode)
344
- .map((node) => [node.id, node]),
345
- );
599
+ const rootProof = verifiedWorkspaceRoot(workspace, { database });
600
+ assertExpectedDiskIdentity(options.expectedDiskIdentity);
601
+ assertExpectedDiskIdentity(options.expectedParentDiskIdentity);
602
+ const before = options.beforeNodes instanceof Map
603
+ ? new Map(options.beforeNodes)
604
+ : new Map(
605
+ database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
606
+ .map(rowToNode)
607
+ .map((node) => [node.id, node]),
608
+ );
346
609
  if (operation.kind === 'tree.move' || operation.kind === 'tree.restore') {
347
610
  const destinationId = operation.parentId || before.get(operation.nodeId)?.parentId;
348
- const destinationRole = options.role
349
- || accessStore.currentUserRole(destinationId, { database });
611
+ // Source and destination are separate authority boundaries. `options.role`
612
+ // may be a source capability already resolved by a caller, but it can
613
+ // never authorize writes into another subtree.
614
+ const destinationRole = accessStore.currentUserRole(destinationId, { database });
350
615
  if (!can(destinationRole, 'create')) {
351
616
  throw invalid('tree destination is forbidden', 403);
352
617
  }
@@ -365,7 +630,19 @@ function commitTreeOperation(input, options = {}) {
365
630
  const replica = shouldSync(targetRelativePath)
366
631
  ? require('../state/replicas').getReplicaByLocalResource(localResource, { database })
367
632
  : null;
633
+ if (replica) {
634
+ const projectedAuthority = Number(database.prepare(`
635
+ SELECT authority_epoch FROM workspace_access_state WHERE workspace_id = ?
636
+ `).get(workspace.id)?.authority_epoch || replica.authorityEpoch);
637
+ require('./tree/floor').ensure({
638
+ schemaVersion: 1,
639
+ workspaceId: workspace.id,
640
+ version: replica.appliedVersion,
641
+ nodes: before,
642
+ }, projectedAuthority, { database });
643
+ }
368
644
  let paths = null;
645
+ let held = null;
369
646
 
370
647
  const result = require('../state/mutations').commitLocalMutation({
371
648
  channelId: replica ? `resource:${replica.resourceId}` : localResource,
@@ -381,7 +658,7 @@ function commitTreeOperation(input, options = {}) {
381
658
  operation,
382
659
  }, {
383
660
  database,
384
- materialize() {
661
+ materialize(journaled) {
385
662
  if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
386
663
  const affectedNodeIds = new Set();
387
664
  for (const node of before.values()) {
@@ -398,12 +675,77 @@ function commitTreeOperation(input, options = {}) {
398
675
  }
399
676
  require('../state/docs').closeDocsByNodeIds(affectedNodeIds);
400
677
  }
401
- paths = options.diskAlreadyApplied === true
402
- ? { oldPath: null, newPath: null }
403
- : materializeOperation(workspace, before, after, operation);
404
- database.transaction(() => {
405
- paths.changedPaths = persistNodes(database, before, after, workspace.id);
678
+ const cloud = require('./tree-cloud');
679
+ const materialization = require('./tree-materialization');
680
+ held = materialization.insertOperation(
681
+ cloud.localTreeOperationPlan(
682
+ workspace,
683
+ before,
684
+ after,
685
+ operation,
686
+ journaled,
687
+ {
688
+ diskAlreadyApplied: options.diskAlreadyApplied === true,
689
+ source: options.source,
690
+ rootIdentity: rootProof.identity,
691
+ },
692
+ ),
693
+ database,
694
+ );
695
+ if (typeof options.testHooks?.afterOperationPlan === 'function') {
696
+ options.testHooks.afterOperationPlan(held.plan);
697
+ }
698
+ try {
699
+ held = cloud.crossTreeOperationWithRaceCapture(
700
+ held,
701
+ database,
702
+ options.testHooks,
703
+ );
704
+ } catch (error) {
705
+ cloud.holdClassifiedPlanRetry(held.plan, error, database);
706
+ }
707
+ const changedPaths = database.transaction(() => {
708
+ assertExpectedDiskIdentity(options.expectedDiskIdentity);
709
+ assertExpectedDiskIdentity(options.expectedParentDiskIdentity);
710
+ const projected = cloud.projectStoredTreeOperation(held, database);
711
+ if (
712
+ typeof options.testHooks
713
+ ?.afterOperationDatabaseProjectionBeforeIdentityFence
714
+ === 'function'
715
+ ) {
716
+ options.testHooks.afterOperationDatabaseProjectionBeforeIdentityFence(
717
+ held.plan,
718
+ );
719
+ }
720
+ // The projection and the final physical fence are one SQLite
721
+ // transaction. A same-kind replacement can therefore make the birth
722
+ // fail, but it can never retain the adopted UUID in workspace_nodes.
723
+ assertExpectedDiskIdentity(options.expectedDiskIdentity);
724
+ assertExpectedDiskIdentity(options.expectedParentDiskIdentity);
725
+ return projected;
406
726
  })();
727
+ const materializedAfter = new Map(
728
+ (held.plan.afterNodes || []).map((node) => [node.id, node]),
729
+ );
730
+ const materializedTarget = materializedAfter.get(operation.nodeId);
731
+ paths = {
732
+ oldPath: targetBefore
733
+ ? operationPathForResult(workspace, before, targetBefore.id)
734
+ : null,
735
+ newPath: materializedTarget
736
+ && !materializedTarget.trashedAt
737
+ && !materializedTarget.purgedAt
738
+ ? operationPathForResult(
739
+ workspace,
740
+ materializedAfter,
741
+ materializedTarget.id,
742
+ )
743
+ : null,
744
+ changedPaths,
745
+ };
746
+ if (options.testHooks?.crashAfterOperationProjection === true) {
747
+ throw new Error('simulated workspace tree crash after local operation projection');
748
+ }
407
749
  },
408
750
  event: () => ({
409
751
  resource: localResource,
@@ -412,8 +754,22 @@ function commitTreeOperation(input, options = {}) {
412
754
  patch: operation,
413
755
  source: options.source || 'workspace-tree:local',
414
756
  }),
757
+ finalize() {
758
+ if (!held) return;
759
+ held = require('./tree-materialization').markOperationCommitted(held, database);
760
+ if (typeof options.persistObservedOperation === 'function') {
761
+ options.persistObservedOperation(operation, mutationId, database);
762
+ }
763
+ },
415
764
  });
416
765
 
766
+ if (held) {
767
+ if (options.testHooks?.crashAfterOperationCommit === true) {
768
+ throw new Error('simulated workspace tree crash after local operation commit');
769
+ }
770
+ require('./tree-cloud').finishTreeOperationPlan(held, database, options.testHooks);
771
+ }
772
+
417
773
  const changedPaths = paths?.changedPaths || [];
418
774
  if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
419
775
  return { operation, mutation: result.mutation, event: result.event, paths };
@@ -518,11 +874,18 @@ function commitPathCreate(pathInput, nodeKind, options = {}) {
518
874
  function ensureNodeForPath(pathInput, options = {}) {
519
875
  const targetPath = path.resolve(pathInput);
520
876
  const database = options.database || openLocalDb();
877
+ const workspace = workspaceForPath(targetPath, database);
878
+ if (!workspace) return null;
879
+ const rootProof = inspectWorkspaceRoot(workspace, database);
880
+ if (rootProof.state === 'unbound') {
881
+ enrollWorkspace(workspace.id, { database, maxNodes: null });
882
+ } else if (rootProof.state !== 'verified') {
883
+ observerRootHold(workspace, rootProof, database);
884
+ throw rootBindingError(rootProof);
885
+ }
521
886
  const existing = nodeForPath(targetPath, { database });
522
887
  if (existing) return existing;
523
888
 
524
- const workspace = workspaceForPath(targetPath, database);
525
- if (!workspace) return null;
526
889
  const relative = relativeWorkspacePath(workspace.path, targetPath);
527
890
  if (relative == null || !shouldEnroll(relative)) return null;
528
891
  const stats = lstatOrNull(targetPath);
@@ -573,7 +936,9 @@ function enrollRegisteredWorkspaces(options = {}) {
573
936
  for (const workspace of workspaces) {
574
937
  try {
575
938
  const result = enrollWorkspace(workspace.id, { database, maxNodes: options.maxNodes });
576
- if (!result.skipped) ensureWorkspaceTreePromotion(workspace.id, { database });
939
+ if (result.state === 'ready') {
940
+ ensureWorkspaceTreePromotion(workspace.id, { database });
941
+ }
577
942
  results.push(result);
578
943
  } catch (error) {
579
944
  console.warn(`[WorkspaceTree] enrollment failed for ${workspace.id}:`, error?.message || error);
@@ -590,9 +955,31 @@ function enrollRegisteredWorkspaces(options = {}) {
590
955
  function reconcileWorkspaceTree(identifier, options = {}) {
591
956
  const database = options.database || openLocalDb();
592
957
  const workspace = workspaceRow(identifier, database);
593
- if (!workspace || !fs.existsSync(workspace.path)) return { operations: [] };
958
+ if (!workspace) return { operations: [] };
959
+ const rootProof = inspectWorkspaceRoot(workspace, database);
960
+ if (rootProof.state === 'unbound') {
961
+ // A partial or skipped baseline is still baseline state. Reconciliation
962
+ // may never reinterpret those existing bytes as new mutations.
963
+ return enrollWorkspace(workspace.id, { database, maxNodes: null });
964
+ }
965
+ if (rootProof.state !== 'verified') {
966
+ return observerRootHold(workspace, rootProof, database);
967
+ }
594
968
  if (isGitOwnedPath(workspace.path)) {
595
- return { workspaceId: workspace.id, operations: [], skipped: 'git-owned' };
969
+ clearObserverRootHolds(
970
+ workspace,
971
+ database,
972
+ 'workspace-tree:excluded-root-verified',
973
+ );
974
+ return {
975
+ workspaceId: workspace.id,
976
+ state: 'excluded',
977
+ operations: [],
978
+ skipped: 'git-owned',
979
+ };
980
+ }
981
+ if (options.skipRecovery !== true) {
982
+ require('./tree-cloud').recoverAllTreePlans({ database });
596
983
  }
597
984
  const maxNodes = treeNodeLimit(options);
598
985
  const existingNodeCount = database.prepare(
@@ -609,12 +996,43 @@ function reconcileWorkspaceTree(identifier, options = {}) {
609
996
 
610
997
  const applyObserved = (operation) => {
611
998
  try {
612
- const result = commitTreeOperation(operation, { database, source, diskAlreadyApplied: true });
999
+ const result = commitTreeOperation(operation, {
1000
+ database,
1001
+ source,
1002
+ diskAlreadyApplied: true,
1003
+ persistObservedOperation: options.persistObservedOperation,
1004
+ });
613
1005
  operations.push(result.operation);
614
1006
  return true;
615
1007
  } catch (error) {
616
- if (error?.statusCode !== 403) throw error;
617
- held.push({ operation, reason: 'forbidden' });
1008
+ const heldReason = error?.code === 'WORKSPACE_TREE_SYMLINK_ESCAPE'
1009
+ ? 'symlink-escapes-workspace'
1010
+ : error?.statusCode === 403
1011
+ ? 'forbidden'
1012
+ : null;
1013
+ if (!heldReason) throw error;
1014
+ const current = operation.nodeId
1015
+ ? database.prepare(`
1016
+ SELECT relative_path
1017
+ FROM workspace_nodes
1018
+ WHERE workspace_id = ? AND id = ?
1019
+ `).get(workspace.id, operation.nodeId)
1020
+ : null;
1021
+ const parent = operation.parentId
1022
+ ? database.prepare(`
1023
+ SELECT relative_path
1024
+ FROM workspace_nodes
1025
+ WHERE workspace_id = ? AND id = ?
1026
+ `).get(workspace.id, operation.parentId)
1027
+ : null;
1028
+ const heldPath = operation.kind === 'tree.create' && parent
1029
+ ? [parent.relative_path, operation.name].filter(Boolean).join('/')
1030
+ : current?.relative_path || null;
1031
+ held.push({
1032
+ operation,
1033
+ reason: heldReason,
1034
+ paths: heldPath ? [heldPath] : [],
1035
+ });
618
1036
  return false;
619
1037
  }
620
1038
  };
@@ -633,11 +1051,51 @@ function reconcileWorkspaceTree(identifier, options = {}) {
633
1051
  includeTrashed: true,
634
1052
  includeMissing: true,
635
1053
  });
1054
+ // Enrollment may have observed an absence before this reconciler started.
1055
+ // That durable `missing_at` value is not a terminal tombstone: it is the
1056
+ // exact offline deletion the tree rail still needs to journal. Collapse a
1057
+ // missing subtree to its highest roots and birth the same `tree.trash`
1058
+ // operations an online observer would have produced.
1059
+ const preexistingMissingIds = new Set(
1060
+ rows.filter((node) => (
1061
+ node.id !== workspace.id
1062
+ && node.missingAt
1063
+ && !node.trashedAt
1064
+ && !node.purgedAt
1065
+ )).map((node) => node.id),
1066
+ );
1067
+ const preexistingById = new Map(rows.map((node) => [node.id, node]));
1068
+ for (const nodeId of preexistingMissingIds) {
1069
+ const node = preexistingById.get(nodeId);
1070
+ if (!node || hasAncestorInSet(node, preexistingById, preexistingMissingIds)) continue;
1071
+ if (!applyObserved({
1072
+ kind: 'tree.trash',
1073
+ workspaceId: workspace.id,
1074
+ nodeId,
1075
+ trashedAt: node.missingAt,
1076
+ })) {
1077
+ heldNodeIds.add(nodeId);
1078
+ }
1079
+ }
1080
+ if (preexistingMissingIds.size) {
1081
+ rows = listWorkspaceNodes(workspace.id, {
1082
+ database,
1083
+ includeTrashed: true,
1084
+ includeMissing: true,
1085
+ });
1086
+ }
636
1087
  const scannedByPath = new Map(scanned.map((entry) => [entry.relativePath, entry]));
637
1088
  const liveRows = rows.filter(
638
1089
  (node) => node.id !== workspace.id && !node.missingAt && !node.trashedAt && !node.purgedAt,
639
1090
  );
640
1091
  const liveByPath = new Map(liveRows.map((node) => [node.relativePath, node]));
1092
+ const scannedByIdentity = new Map();
1093
+ for (const entry of scanned) {
1094
+ const key = `${entry.deviceId}:${entry.inode}`;
1095
+ const values = scannedByIdentity.get(key) || [];
1096
+ values.push(entry);
1097
+ scannedByIdentity.set(key, values);
1098
+ }
641
1099
  const liveByInode = new Map();
642
1100
  for (const node of liveRows) {
643
1101
  if (!node.deviceId || !node.inode) continue;
@@ -646,21 +1104,204 @@ function reconcileWorkspaceTree(identifier, options = {}) {
646
1104
  matches.push(node);
647
1105
  liveByInode.set(key, matches);
648
1106
  }
1107
+ const physicalParentNodeId = (entry) => {
1108
+ if (!entry?.parentRelativePath) return workspace.id;
1109
+ const parentEntry = scannedByPath.get(entry.parentRelativePath);
1110
+ if (!parentEntry) return null;
1111
+ const matches = (
1112
+ liveByInode.get(`${parentEntry.deviceId}:${parentEntry.inode}`) || []
1113
+ ).filter((node) => node.kind === 'folder');
1114
+ return matches.length === 1 ? matches[0].id : null;
1115
+ };
1116
+ const samePhysicalEntry = (node, entry) => Boolean(
1117
+ node
1118
+ && entry
1119
+ && node.kind === entry.kind
1120
+ && physicalParentNodeId(entry) === node.parentId
1121
+ && (
1122
+ !node.deviceId
1123
+ || !node.inode
1124
+ || (
1125
+ node.deviceId === entry.deviceId
1126
+ && node.inode === entry.inode
1127
+ )
1128
+ || (
1129
+ // A file UUID names the document channel, not one storage inode.
1130
+ // Editors commonly save by atomically replacing the inode at the same
1131
+ // path. That replacement is the same channel only when the former
1132
+ // inode did not move elsewhere; a moved original plus a new file at
1133
+ // its old spelling is a rename followed by a distinct birth.
1134
+ node.kind === 'file'
1135
+ && !scannedByIdentity.has(`${node.deviceId}:${node.inode}`)
1136
+ )
1137
+ )
1138
+ );
1139
+ const refreshSamePathFileBinding = (node, entry) => {
1140
+ if (
1141
+ node.kind !== 'file'
1142
+ || (
1143
+ node.deviceId === entry.deviceId
1144
+ && node.inode === entry.inode
1145
+ )
1146
+ ) {
1147
+ return;
1148
+ }
1149
+ const absoluteEntry = path.join(
1150
+ workspace.path,
1151
+ ...entry.relativePath.split('/'),
1152
+ );
1153
+ const actual = lstatOrNull(absoluteEntry);
1154
+ if (
1155
+ !actual
1156
+ || !actual.isFile()
1157
+ || String(actual.dev) !== entry.deviceId
1158
+ || String(actual.ino) !== entry.inode
1159
+ ) {
1160
+ heldNodeIds.add(node.id);
1161
+ heldPaths.add(entry.relativePath);
1162
+ held.push({
1163
+ nodeId: node.id,
1164
+ paths: [entry.relativePath],
1165
+ reason: 'physical-binding-changed',
1166
+ });
1167
+ return;
1168
+ }
1169
+ const updated = database.prepare(`
1170
+ UPDATE workspace_nodes
1171
+ SET device_id = ?, inode = ?, missing_at = NULL
1172
+ WHERE workspace_id = ? AND id = ?
1173
+ AND relative_path = ? AND kind = 'file'
1174
+ AND device_id IS ? AND inode IS ?
1175
+ AND missing_at IS NULL AND trashed_at IS NULL AND purged_at IS NULL
1176
+ `).run(
1177
+ entry.deviceId,
1178
+ entry.inode,
1179
+ workspace.id,
1180
+ node.id,
1181
+ entry.relativePath,
1182
+ node.deviceId,
1183
+ node.inode,
1184
+ );
1185
+ if (updated.changes !== 1) {
1186
+ heldNodeIds.add(node.id);
1187
+ heldPaths.add(entry.relativePath);
1188
+ held.push({
1189
+ nodeId: node.id,
1190
+ paths: [entry.relativePath],
1191
+ reason: 'physical-binding-changed',
1192
+ });
1193
+ return;
1194
+ }
1195
+ node.deviceId = entry.deviceId;
1196
+ node.inode = entry.inode;
1197
+ };
649
1198
 
650
1199
  const renamedIds = new Set();
1200
+ const deferredSurvivors = new Map();
1201
+ const liveRowsById = new Map(rows.map((node) => [node.id, node]));
1202
+ const holdAmbiguousPhysicalNames = (
1203
+ node,
1204
+ physicalEntries,
1205
+ reason = 'ambiguous-physical-alias',
1206
+ ) => {
1207
+ heldNodeIds.add(node.id);
1208
+ let current = node;
1209
+ for (;;) {
1210
+ const parent = liveRowsById.get(current.parentId);
1211
+ if (
1212
+ !parent
1213
+ || parent.id === workspace.id
1214
+ || samePhysicalEntry(
1215
+ parent,
1216
+ scannedByPath.get(parent.relativePath),
1217
+ )
1218
+ ) {
1219
+ break;
1220
+ }
1221
+ heldNodeIds.add(parent.id);
1222
+ current = parent;
1223
+ }
1224
+ for (const candidate of physicalEntries) {
1225
+ heldPaths.add(candidate.relativePath);
1226
+ }
1227
+ held.push({
1228
+ nodeId: node.id,
1229
+ paths: physicalEntries.map((candidate) => candidate.relativePath),
1230
+ reason,
1231
+ });
1232
+ };
1233
+ for (const aliases of physicalAliasGroups(scanned)) {
1234
+ const key = `${aliases[0].deviceId}:${aliases[0].inode}`;
1235
+ const nodes = new Map();
1236
+ for (const entry of aliases) {
1237
+ const atPath = liveByPath.get(entry.relativePath);
1238
+ if (atPath) nodes.set(atPath.id, atPath);
1239
+ }
1240
+ for (const node of liveByInode.get(key) || []) {
1241
+ nodes.set(node.id, node);
1242
+ }
1243
+ if (!nodes.size) {
1244
+ for (const entry of aliases) heldPaths.add(entry.relativePath);
1245
+ held.push({
1246
+ nodeId: null,
1247
+ paths: aliases.map((entry) => entry.relativePath),
1248
+ reason: 'ambiguous-physical-alias',
1249
+ });
1250
+ continue;
1251
+ }
1252
+ for (const node of nodes.values()) {
1253
+ holdAmbiguousPhysicalNames(node, aliases);
1254
+ }
1255
+ }
651
1256
  for (const entry of scanned) {
652
- if (liveByPath.has(entry.relativePath)) continue;
1257
+ if (heldPaths.has(entry.relativePath)) continue;
653
1258
  const matches = (liveByInode.get(`${entry.deviceId}:${entry.inode}`) || [])
654
1259
  .filter((node) => !renamedIds.has(node.id));
655
- if (matches.length !== 1) continue;
1260
+ if (matches.length > 1) {
1261
+ for (const node of matches) {
1262
+ if (!heldNodeIds.has(node.id)) {
1263
+ holdAmbiguousPhysicalNames(
1264
+ node,
1265
+ [entry],
1266
+ 'ambiguous-physical-ownership',
1267
+ );
1268
+ }
1269
+ }
1270
+ continue;
1271
+ }
1272
+ const atPath = liveByPath.get(entry.relativePath);
1273
+ if (samePhysicalEntry(atPath, entry)) {
1274
+ refreshSamePathFileBinding(atPath, entry);
1275
+ continue;
1276
+ }
1277
+ if (matches.length !== 1) {
1278
+ continue;
1279
+ }
656
1280
  const node = matches[0];
1281
+ const physicalEntries = (
1282
+ scannedByIdentity.get(`${entry.deviceId}:${entry.inode}`) || []
1283
+ );
1284
+ if (physicalEntries.length !== 1) {
1285
+ if (!heldNodeIds.has(node.id)) {
1286
+ holdAmbiguousPhysicalNames(node, physicalEntries);
1287
+ }
1288
+ continue;
1289
+ }
657
1290
  const rowsById = new Map(rows.map((item) => [item.id, item]));
658
1291
  if (hasAncestorInSet(node, rowsById, renamedIds)) continue;
659
- const parent = nodeForPath(path.dirname(path.join(workspace.path, ...entry.relativePath.split('/'))), { database });
660
- if (!parent) continue;
661
- const applied = applyObserved(parent.node.id === node.parentId
1292
+ const parentId = physicalParentNodeId(entry);
1293
+ if (!parentId) {
1294
+ // The exact inode survived, but its physical parent is a replacement
1295
+ // that has no UUID yet. Reserve this path. After the old parent is
1296
+ // trashed and the replacement is born, restore/move this same UUID
1297
+ // under that newly proven parent.
1298
+ deferredSurvivors.set(node.id, entry);
1299
+ heldPaths.add(entry.relativePath);
1300
+ continue;
1301
+ }
1302
+ const applied = applyObserved(parentId === node.parentId
662
1303
  ? { kind: 'tree.rename', workspaceId: workspace.id, nodeId: node.id, name: entry.name }
663
- : { kind: 'tree.move', workspaceId: workspace.id, nodeId: node.id, parentId: parent.node.id, name: entry.name });
1304
+ : { kind: 'tree.move', workspaceId: workspace.id, nodeId: node.id, parentId, name: entry.name });
664
1305
  if (!applied) {
665
1306
  heldNodeIds.add(node.id);
666
1307
  heldPaths.add(entry.relativePath);
@@ -669,41 +1310,204 @@ function reconcileWorkspaceTree(identifier, options = {}) {
669
1310
  renamedIds.add(node.id);
670
1311
  }
671
1312
 
672
- for (const entry of scanned) {
673
- if (heldPaths.has(entry.relativePath)) continue;
674
- if (nodeForPath(path.join(workspace.path, ...entry.relativePath.split('/')), { database })) continue;
1313
+ // A path spelling is not identity. After recognizing unique physical
1314
+ // renames, retire every old UUID whose exact kind/device/inode is no longer
1315
+ // present at its recorded path before birthing a replacement at that same
1316
+ // spelling. If the old inode still has ambiguous hardlink names, hold it
1317
+ // instead of guessing which name owns the channel.
1318
+ rows = listWorkspaceNodes(workspace.id, {
1319
+ database,
1320
+ includeTrashed: true,
1321
+ includeMissing: true,
1322
+ });
1323
+ let currentLive = rows.filter(
1324
+ (node) => node.id !== workspace.id && !node.missingAt && !node.trashedAt && !node.purgedAt,
1325
+ );
1326
+ const missingIds = new Set();
1327
+ for (const node of currentLive) {
1328
+ if (heldNodeIds.has(node.id)) continue;
1329
+ if (samePhysicalEntry(node, scannedByPath.get(node.relativePath))) continue;
1330
+ if (
1331
+ isGitOwnedPath(
1332
+ path.join(workspace.path, ...node.relativePath.split('/')),
1333
+ )
1334
+ ) continue;
1335
+ const physical = node.deviceId && node.inode
1336
+ ? scannedByIdentity.get(`${node.deviceId}:${node.inode}`) || []
1337
+ : [];
1338
+ if (physical.length) {
1339
+ if (deferredSurvivors.has(node.id)) continue;
1340
+ if (!heldNodeIds.has(node.id)) {
1341
+ holdAmbiguousPhysicalNames(
1342
+ node,
1343
+ physical,
1344
+ 'ambiguous-physical-ownership',
1345
+ );
1346
+ }
1347
+ continue;
1348
+ }
1349
+ missingIds.add(node.id);
1350
+ }
1351
+ const currentById = new Map(rows.map((node) => [node.id, node]));
1352
+ for (const node of currentLive) {
1353
+ if (
1354
+ !missingIds.has(node.id)
1355
+ || hasAncestorInSet(node, currentById, missingIds)
1356
+ ) continue;
1357
+ applyObserved({
1358
+ kind: 'tree.trash',
1359
+ workspaceId: workspace.id,
1360
+ nodeId: node.id,
1361
+ });
1362
+ }
1363
+
1364
+ const birthUnknownEntries = () => {
1365
+ for (const entry of scanned) {
1366
+ if (heldPaths.has(entry.relativePath)) continue;
1367
+ const absoluteEntry = path.join(
1368
+ workspace.path,
1369
+ ...entry.relativePath.split('/'),
1370
+ );
1371
+ if (nodeForPath(absoluteEntry, { database })) continue;
1372
+ const parent = nodeForPath(
1373
+ entry.parentRelativePath
1374
+ ? path.join(workspace.path, ...entry.parentRelativePath.split('/'))
1375
+ : workspace.path,
1376
+ { database },
1377
+ );
1378
+ if (!parent || heldNodeIds.has(parent.node.id)) continue;
1379
+ if (!applyObserved({
1380
+ kind: 'tree.create',
1381
+ workspaceId: workspace.id,
1382
+ nodeId: uuidV7(),
1383
+ parentId: parent.node.id,
1384
+ nodeKind: entry.kind,
1385
+ name: entry.name,
1386
+ symlinkTarget: entry.symlinkTarget,
1387
+ })) {
1388
+ heldPaths.add(entry.relativePath);
1389
+ }
1390
+ }
1391
+ };
1392
+ birthUnknownEntries();
1393
+
1394
+ for (const [nodeId, entry] of Array.from(deferredSurvivors.entries()).sort(
1395
+ (left, right) => (
1396
+ left[1].relativePath.split('/').length
1397
+ - right[1].relativePath.split('/').length
1398
+ ),
1399
+ )) {
675
1400
  const parent = nodeForPath(
676
1401
  entry.parentRelativePath
677
1402
  ? path.join(workspace.path, ...entry.parentRelativePath.split('/'))
678
1403
  : workspace.path,
679
1404
  { database },
680
1405
  );
681
- if (!parent) continue;
1406
+ const current = rowToNode(database.prepare(`
1407
+ SELECT * FROM workspace_nodes
1408
+ WHERE workspace_id = ? AND id = ? AND purged_at IS NULL
1409
+ `).get(workspace.id, nodeId));
1410
+ if (!parent || !current) continue;
1411
+ const operation = current.trashedAt
1412
+ ? {
1413
+ kind: 'tree.restore',
1414
+ workspaceId: workspace.id,
1415
+ nodeId,
1416
+ parentId: parent.node.id,
1417
+ name: entry.name,
1418
+ }
1419
+ : parent.node.id === current.parentId
1420
+ ? {
1421
+ kind: 'tree.rename',
1422
+ workspaceId: workspace.id,
1423
+ nodeId,
1424
+ name: entry.name,
1425
+ }
1426
+ : {
1427
+ kind: 'tree.move',
1428
+ workspaceId: workspace.id,
1429
+ nodeId,
1430
+ parentId: parent.node.id,
1431
+ name: entry.name,
1432
+ };
1433
+ if (!applyObserved(operation)) {
1434
+ heldNodeIds.add(nodeId);
1435
+ continue;
1436
+ }
1437
+ renamedIds.add(nodeId);
1438
+ }
1439
+
1440
+ // Restoring a surviving folder also restores descendants that shared its
1441
+ // old trash cascade. Re-apply the same sealed scan immediately so an absent
1442
+ // descendant is re-trashed before this reconciliation finishes.
1443
+ rows = listWorkspaceNodes(workspace.id, {
1444
+ database,
1445
+ includeTrashed: true,
1446
+ includeMissing: true,
1447
+ });
1448
+ const finalLive = rows.filter(
1449
+ (node) => (
1450
+ node.id !== workspace.id
1451
+ && !node.missingAt
1452
+ && !node.trashedAt
1453
+ && !node.purgedAt
1454
+ ),
1455
+ );
1456
+ const finalMissingIds = new Set();
1457
+ for (const node of finalLive) {
1458
+ if (heldNodeIds.has(node.id)) continue;
1459
+ const entry = scannedByPath.get(node.relativePath);
1460
+ const absoluteParent = entry?.parentRelativePath
1461
+ ? path.join(workspace.path, ...entry.parentRelativePath.split('/'))
1462
+ : workspace.path;
1463
+ const currentParent = entry
1464
+ ? nodeForPath(absoluteParent, { database })?.node
1465
+ : null;
1466
+ const exactEntry = Boolean(
1467
+ entry
1468
+ && currentParent?.id === node.parentId
1469
+ && entry.kind === node.kind
1470
+ && (
1471
+ node.kind === 'file'
1472
+ || !node.deviceId
1473
+ || !node.inode
1474
+ || (
1475
+ node.deviceId === entry.deviceId
1476
+ && node.inode === entry.inode
1477
+ )
1478
+ )
1479
+ );
1480
+ if (exactEntry) continue;
1481
+ const physical = node.deviceId && node.inode
1482
+ ? scannedByIdentity.get(`${node.deviceId}:${node.inode}`) || []
1483
+ : [];
1484
+ if (physical.length) continue;
1485
+ finalMissingIds.add(node.id);
1486
+ }
1487
+ const finalById = new Map(rows.map((node) => [node.id, node]));
1488
+ for (const node of finalLive) {
1489
+ if (
1490
+ !finalMissingIds.has(node.id)
1491
+ || hasAncestorInSet(node, finalById, finalMissingIds)
1492
+ ) continue;
682
1493
  applyObserved({
683
- kind: 'tree.create',
1494
+ kind: 'tree.trash',
684
1495
  workspaceId: workspace.id,
685
- nodeId: uuidV7(),
686
- parentId: parent.node.id,
687
- nodeKind: entry.kind,
688
- name: entry.name,
689
- symlinkTarget: entry.symlinkTarget,
1496
+ nodeId: node.id,
690
1497
  });
691
1498
  }
692
-
693
- rows = listWorkspaceNodes(workspace.id, { database });
694
- const rowsById = new Map(rows.map((node) => [node.id, node]));
695
- const missingIds = new Set(
696
- rows
697
- .filter((node) => node.id !== workspace.id && !scannedByPath.has(node.relativePath))
698
- .filter((node) => !heldNodeIds.has(node.id))
699
- .filter((node) => !isGitOwnedPath(path.join(workspace.path, ...node.relativePath.split('/'))))
700
- .map((node) => node.id),
1499
+ birthUnknownEntries();
1500
+ require('./store').commitCompletedTreeObservation(
1501
+ workspace.id,
1502
+ held,
1503
+ { database },
701
1504
  );
702
- for (const node of rows) {
703
- if (!missingIds.has(node.id) || hasAncestorInSet(node, rowsById, missingIds)) continue;
704
- applyObserved({ kind: 'tree.trash', workspaceId: workspace.id, nodeId: node.id });
705
- }
706
- return { workspaceId: workspace.id, operations, held };
1505
+ return {
1506
+ workspaceId: workspace.id,
1507
+ state: held.length ? 'held' : 'ready',
1508
+ operations,
1509
+ held,
1510
+ };
707
1511
  }
708
1512
 
709
1513
  function reconcileRegisteredWorkspaceTrees(options = {}) {
@@ -720,16 +1524,32 @@ function reconcileRegisteredWorkspaceTrees(options = {}) {
720
1524
  }
721
1525
 
722
1526
  let enrollmentStarted = false;
723
- function startTreeEnrollmentService() {
724
- if (enrollmentStarted) return;
725
- enrollmentStarted = true;
1527
+ let enrollmentObserversStarted = false;
1528
+
1529
+ function startRecoveredTreeObservers() {
1530
+ if (enrollmentObserversStarted) return;
1531
+ try {
1532
+ require('./tree-cloud').recoverAllTreePlans({ database: openLocalDb() });
1533
+ } catch (error) {
1534
+ console.warn('[WorkspaceTree] recovery must finish before observation:', error?.message || error);
1535
+ const retry = setTimeout(startRecoveredTreeObservers, 2_000);
1536
+ if (typeof retry.unref === 'function') retry.unref();
1537
+ return;
1538
+ }
1539
+ enrollmentObserversStarted = true;
726
1540
  const timer = setTimeout(
727
- () => enrollRegisteredWorkspaces({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
1541
+ () => {
1542
+ // This pass performs one-time baseline enrollment only for roots that
1543
+ // have never been physically bound. Already enrolled roots delegate to
1544
+ // the journaled observer, so startup cannot absorb or clear a held disk
1545
+ // change as new baseline state.
1546
+ enrollRegisteredWorkspaces();
1547
+ },
728
1548
  0,
729
1549
  );
730
1550
  if (typeof timer.unref === 'function') timer.unref();
731
1551
  const observationTimer = setInterval(
732
- () => reconcileRegisteredWorkspaceTrees({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
1552
+ () => reconcileRegisteredWorkspaceTrees(),
733
1553
  2_000,
734
1554
  );
735
1555
  if (typeof observationTimer.unref === 'function') observationTimer.unref();
@@ -743,6 +1563,12 @@ function startTreeEnrollmentService() {
743
1563
  if (typeof promotionTimer.unref === 'function') promotionTimer.unref();
744
1564
  }
745
1565
 
1566
+ function startTreeEnrollmentService() {
1567
+ if (enrollmentStarted) return;
1568
+ enrollmentStarted = true;
1569
+ startRecoveredTreeObservers();
1570
+ }
1571
+
746
1572
  function workspaceTreeSnapshot(workspaceId, options = {}) {
747
1573
  return require('./tree-cloud').workspaceTreeSnapshot(workspaceId, options);
748
1574
  }
@@ -783,6 +1609,8 @@ module.exports = {
783
1609
  reconcileRegisteredWorkspaceTrees,
784
1610
  reconcileWorkspaceTree,
785
1611
  requirePathCapability,
1612
+ scanWorkspacePhysicalAliases,
1613
+ verifiedWorkspaceRoot,
786
1614
  workspaceTreeSnapshot,
787
1615
  workspaceForPath,
788
1616
  };