amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -3,13 +3,14 @@
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');
7
6
  const { openLocalDb } = require('../state/db');
8
7
  const { applyTreeOperation } = require('./tree/reducer');
9
8
  const { can } = require('./tree/access');
10
9
  const { relativeWorkspacePath, shouldEnroll, shouldSync } = require('./tree/inclusion');
11
10
  const {
12
11
  hasAncestorInSet,
12
+ materializeOperation,
13
+ persistNodes,
13
14
  relativePathForNode,
14
15
  rowToNode,
15
16
  workspaceRow,
@@ -17,13 +18,13 @@ const {
17
18
  const { normalizeTreeOperation } = require('./tree/operations');
18
19
  const { uuidV7 } = require('./tree/identity');
19
20
  const { isGitOwnedPath } = require('./repo-scope');
20
- const {
21
- ROOT_HOLD_REASONS,
22
- inspectWorkspaceRoot,
23
- rootBindingError,
24
- } = require('./tree/root-binding');
25
21
 
26
22
  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;
27
28
 
28
29
  function invalid(message, statusCode = 400) {
29
30
  return Object.assign(new Error(message), { statusCode });
@@ -57,11 +58,6 @@ function nodeForPath(absolutePath, options = {}) {
57
58
  const database = options.database || openLocalDb();
58
59
  const workspace = workspaceForPath(absolutePath, database);
59
60
  if (!workspace) return null;
60
- const proof = inspectWorkspaceRoot(workspace, database);
61
- if (proof.state !== 'verified') {
62
- observerRootHold(workspace, proof, database);
63
- return null;
64
- }
65
61
  const relativePath = path.resolve(absolutePath) === path.resolve(workspace.path)
66
62
  ? ''
67
63
  : relativeWorkspacePath(workspace.path, path.resolve(absolutePath));
@@ -85,12 +81,6 @@ function absolutePathForNode(nodeId, options = {}) {
85
81
  WHERE workspace_nodes.id = ?
86
82
  `).get(nodeId);
87
83
  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
- }
94
84
  return row.relative_path
95
85
  ? path.join(row.workspace_path, ...row.relative_path.split('/'))
96
86
  : row.workspace_path;
@@ -153,135 +143,6 @@ function scanDirectory(rootPath, options = {}) {
153
143
  return entries;
154
144
  }
155
145
 
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
-
285
146
  /**
286
147
  * Enroll current disk state without turning a path into identity. Existing
287
148
  * paths retain their UUID; a same-filesystem rename is recognized by inode;
@@ -293,52 +154,10 @@ function enrollWorkspace(identifier, options = {}) {
293
154
  const workspace = workspaceRow(identifier, database);
294
155
  if (!workspace) throw invalid('workspace not found', 404);
295
156
  const rootPath = path.resolve(workspace.path || '');
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
- }
157
+ const rootStats = fs.statSync(rootPath);
158
+ if (!rootStats.isDirectory()) throw invalid('workspace path is not a folder', 409);
306
159
  if (isGitOwnedPath(rootPath)) {
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
- };
160
+ return { workspaceId: workspace.id, created: 0, nodes: [], skipped: 'git-owned' };
342
161
  }
343
162
 
344
163
  const maxNodes = treeNodeLimit(options);
@@ -346,11 +165,7 @@ function enrollWorkspace(identifier, options = {}) {
346
165
  'SELECT count(*) AS count FROM workspace_nodes WHERE workspace_id = ?',
347
166
  ).get(workspace.id).count;
348
167
  if (maxNodes != null && existingNodeCount > maxNodes) {
349
- return observerRootHold(
350
- workspace,
351
- { reason: 'workspace-root-enrollment-pending' },
352
- database,
353
- );
168
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
354
169
  }
355
170
 
356
171
  let scanned;
@@ -358,34 +173,10 @@ function enrollWorkspace(identifier, options = {}) {
358
173
  scanned = scanDirectory(rootPath, { maxNodes });
359
174
  } catch (error) {
360
175
  if (error?.code === 'WORKSPACE_TREE_NODE_LIMIT') {
361
- return observerRootHold(
362
- workspace,
363
- { reason: 'workspace-root-enrollment-pending' },
364
- database,
365
- );
176
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
366
177
  }
367
178
  throw error;
368
179
  }
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
- }
389
180
  const existing = database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id);
390
181
  // Missing/trash/purged rows are tombstones, not rename candidates. In
391
182
  // particular, a later file whose inode was recycled must receive a fresh
@@ -412,13 +203,7 @@ function enrollWorkspace(identifier, options = {}) {
412
203
  UPDATE workspace_nodes
413
204
  SET name = ?, device_id = ?, inode = ?, missing_at = NULL, updated_at = ?
414
205
  WHERE id = ?
415
- `).run(
416
- workspace.name,
417
- rootProof.actual.deviceId,
418
- rootProof.actual.inode,
419
- now,
420
- workspace.id,
421
- );
206
+ `).run(workspace.name, String(rootStats.dev), String(rootStats.ino), now, workspace.id);
422
207
 
423
208
  for (const entry of scanned) {
424
209
  const parentId = nodeIdByPath.get(entry.parentRelativePath);
@@ -500,25 +285,14 @@ function enrollWorkspace(identifier, options = {}) {
500
285
  console.warn('[WorkspaceTree] live document rebind after scan failed:', error?.message || error);
501
286
  }
502
287
  }
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
- };
288
+ return { workspaceId: workspace.id, created, renamed: renamed.length, nodes: listWorkspaceNodes(workspace.id, { database }) };
515
289
  }
516
290
 
517
291
  function lstatOrNull(targetPath) {
518
292
  try {
519
293
  return fs.lstatSync(targetPath);
520
294
  } catch (error) {
521
- if (isPathAbsentError(error)) return null;
295
+ if (error?.code === 'ENOENT') return null;
522
296
  throw error;
523
297
  }
524
298
  }
@@ -555,38 +329,6 @@ function requirePathCapability(pathInput, capability, options = {}) {
555
329
  return { ...found, role };
556
330
  }
557
331
 
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
-
590
332
  function commitTreeOperation(input, options = {}) {
591
333
  let operation = normalizeTreeOperation(input);
592
334
  const database = options.database || openLocalDb();
@@ -596,22 +338,15 @@ function commitTreeOperation(input, options = {}) {
596
338
  if (!can(role, capabilityForOperation(operation.kind))) throw invalid('tree operation is forbidden', 403);
597
339
  const workspace = workspaceRow(operation.workspaceId, database);
598
340
  if (!workspace) throw invalid('workspace not found', 404);
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
- );
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
+ );
609
346
  if (operation.kind === 'tree.move' || operation.kind === 'tree.restore') {
610
347
  const destinationId = operation.parentId || before.get(operation.nodeId)?.parentId;
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 });
348
+ const destinationRole = options.role
349
+ || accessStore.currentUserRole(destinationId, { database });
615
350
  if (!can(destinationRole, 'create')) {
616
351
  throw invalid('tree destination is forbidden', 403);
617
352
  }
@@ -630,19 +365,7 @@ function commitTreeOperation(input, options = {}) {
630
365
  const replica = shouldSync(targetRelativePath)
631
366
  ? require('../state/replicas').getReplicaByLocalResource(localResource, { database })
632
367
  : 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
- }
644
368
  let paths = null;
645
- let held = null;
646
369
 
647
370
  const result = require('../state/mutations').commitLocalMutation({
648
371
  channelId: replica ? `resource:${replica.resourceId}` : localResource,
@@ -658,7 +381,7 @@ function commitTreeOperation(input, options = {}) {
658
381
  operation,
659
382
  }, {
660
383
  database,
661
- materialize(journaled) {
384
+ materialize() {
662
385
  if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
663
386
  const affectedNodeIds = new Set();
664
387
  for (const node of before.values()) {
@@ -675,77 +398,12 @@ function commitTreeOperation(input, options = {}) {
675
398
  }
676
399
  require('../state/docs').closeDocsByNodeIds(affectedNodeIds);
677
400
  }
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;
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);
726
406
  })();
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
- }
749
407
  },
750
408
  event: () => ({
751
409
  resource: localResource,
@@ -754,22 +412,8 @@ function commitTreeOperation(input, options = {}) {
754
412
  patch: operation,
755
413
  source: options.source || 'workspace-tree:local',
756
414
  }),
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
- },
764
415
  });
765
416
 
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
-
773
417
  const changedPaths = paths?.changedPaths || [];
774
418
  if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
775
419
  return { operation, mutation: result.mutation, event: result.event, paths };
@@ -874,18 +518,11 @@ function commitPathCreate(pathInput, nodeKind, options = {}) {
874
518
  function ensureNodeForPath(pathInput, options = {}) {
875
519
  const targetPath = path.resolve(pathInput);
876
520
  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
- }
886
521
  const existing = nodeForPath(targetPath, { database });
887
522
  if (existing) return existing;
888
523
 
524
+ const workspace = workspaceForPath(targetPath, database);
525
+ if (!workspace) return null;
889
526
  const relative = relativeWorkspacePath(workspace.path, targetPath);
890
527
  if (relative == null || !shouldEnroll(relative)) return null;
891
528
  const stats = lstatOrNull(targetPath);
@@ -936,9 +573,7 @@ function enrollRegisteredWorkspaces(options = {}) {
936
573
  for (const workspace of workspaces) {
937
574
  try {
938
575
  const result = enrollWorkspace(workspace.id, { database, maxNodes: options.maxNodes });
939
- if (result.state === 'ready') {
940
- ensureWorkspaceTreePromotion(workspace.id, { database });
941
- }
576
+ if (!result.skipped) ensureWorkspaceTreePromotion(workspace.id, { database });
942
577
  results.push(result);
943
578
  } catch (error) {
944
579
  console.warn(`[WorkspaceTree] enrollment failed for ${workspace.id}:`, error?.message || error);
@@ -955,31 +590,9 @@ function enrollRegisteredWorkspaces(options = {}) {
955
590
  function reconcileWorkspaceTree(identifier, options = {}) {
956
591
  const database = options.database || openLocalDb();
957
592
  const workspace = workspaceRow(identifier, database);
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
- }
593
+ if (!workspace || !fs.existsSync(workspace.path)) return { operations: [] };
968
594
  if (isGitOwnedPath(workspace.path)) {
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 });
595
+ return { workspaceId: workspace.id, operations: [], skipped: 'git-owned' };
983
596
  }
984
597
  const maxNodes = treeNodeLimit(options);
985
598
  const existingNodeCount = database.prepare(
@@ -996,43 +609,12 @@ function reconcileWorkspaceTree(identifier, options = {}) {
996
609
 
997
610
  const applyObserved = (operation) => {
998
611
  try {
999
- const result = commitTreeOperation(operation, {
1000
- database,
1001
- source,
1002
- diskAlreadyApplied: true,
1003
- persistObservedOperation: options.persistObservedOperation,
1004
- });
612
+ const result = commitTreeOperation(operation, { database, source, diskAlreadyApplied: true });
1005
613
  operations.push(result.operation);
1006
614
  return true;
1007
615
  } catch (error) {
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
- });
616
+ if (error?.statusCode !== 403) throw error;
617
+ held.push({ operation, reason: 'forbidden' });
1036
618
  return false;
1037
619
  }
1038
620
  };
@@ -1051,51 +633,11 @@ function reconcileWorkspaceTree(identifier, options = {}) {
1051
633
  includeTrashed: true,
1052
634
  includeMissing: true,
1053
635
  });
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
- }
1087
636
  const scannedByPath = new Map(scanned.map((entry) => [entry.relativePath, entry]));
1088
637
  const liveRows = rows.filter(
1089
638
  (node) => node.id !== workspace.id && !node.missingAt && !node.trashedAt && !node.purgedAt,
1090
639
  );
1091
640
  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
- }
1099
641
  const liveByInode = new Map();
1100
642
  for (const node of liveRows) {
1101
643
  if (!node.deviceId || !node.inode) continue;
@@ -1104,204 +646,21 @@ function reconcileWorkspaceTree(identifier, options = {}) {
1104
646
  matches.push(node);
1105
647
  liveByInode.set(key, matches);
1106
648
  }
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
- };
1198
649
 
1199
650
  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
- }
1256
651
  for (const entry of scanned) {
1257
- if (heldPaths.has(entry.relativePath)) continue;
652
+ if (liveByPath.has(entry.relativePath)) continue;
1258
653
  const matches = (liveByInode.get(`${entry.deviceId}:${entry.inode}`) || [])
1259
654
  .filter((node) => !renamedIds.has(node.id));
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
- }
655
+ if (matches.length !== 1) continue;
1280
656
  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
- }
1290
657
  const rowsById = new Map(rows.map((item) => [item.id, item]));
1291
658
  if (hasAncestorInSet(node, rowsById, renamedIds)) continue;
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
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
1303
662
  ? { kind: 'tree.rename', workspaceId: workspace.id, nodeId: node.id, name: entry.name }
1304
- : { kind: 'tree.move', workspaceId: workspace.id, nodeId: node.id, parentId, name: entry.name });
663
+ : { kind: 'tree.move', workspaceId: workspace.id, nodeId: node.id, parentId: parent.node.id, name: entry.name });
1305
664
  if (!applied) {
1306
665
  heldNodeIds.add(node.id);
1307
666
  heldPaths.add(entry.relativePath);
@@ -1310,204 +669,41 @@ function reconcileWorkspaceTree(identifier, options = {}) {
1310
669
  renamedIds.add(node.id);
1311
670
  }
1312
671
 
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
- )) {
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;
1400
675
  const parent = nodeForPath(
1401
676
  entry.parentRelativePath
1402
677
  ? path.join(workspace.path, ...entry.parentRelativePath.split('/'))
1403
678
  : workspace.path,
1404
679
  { database },
1405
680
  );
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;
681
+ if (!parent) continue;
1493
682
  applyObserved({
1494
- kind: 'tree.trash',
683
+ kind: 'tree.create',
1495
684
  workspaceId: workspace.id,
1496
- nodeId: node.id,
685
+ nodeId: uuidV7(),
686
+ parentId: parent.node.id,
687
+ nodeKind: entry.kind,
688
+ name: entry.name,
689
+ symlinkTarget: entry.symlinkTarget,
1497
690
  });
1498
691
  }
1499
- birthUnknownEntries();
1500
- require('./store').commitCompletedTreeObservation(
1501
- workspace.id,
1502
- held,
1503
- { database },
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),
1504
701
  );
1505
- return {
1506
- workspaceId: workspace.id,
1507
- state: held.length ? 'held' : 'ready',
1508
- operations,
1509
- held,
1510
- };
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 };
1511
707
  }
1512
708
 
1513
709
  function reconcileRegisteredWorkspaceTrees(options = {}) {
@@ -1524,32 +720,16 @@ function reconcileRegisteredWorkspaceTrees(options = {}) {
1524
720
  }
1525
721
 
1526
722
  let enrollmentStarted = false;
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;
723
+ function startTreeEnrollmentService() {
724
+ if (enrollmentStarted) return;
725
+ enrollmentStarted = true;
1540
726
  const timer = setTimeout(
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
- },
727
+ () => enrollRegisteredWorkspaces({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
1548
728
  0,
1549
729
  );
1550
730
  if (typeof timer.unref === 'function') timer.unref();
1551
731
  const observationTimer = setInterval(
1552
- () => reconcileRegisteredWorkspaceTrees(),
732
+ () => reconcileRegisteredWorkspaceTrees({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
1553
733
  2_000,
1554
734
  );
1555
735
  if (typeof observationTimer.unref === 'function') observationTimer.unref();
@@ -1563,12 +743,6 @@ function startRecoveredTreeObservers() {
1563
743
  if (typeof promotionTimer.unref === 'function') promotionTimer.unref();
1564
744
  }
1565
745
 
1566
- function startTreeEnrollmentService() {
1567
- if (enrollmentStarted) return;
1568
- enrollmentStarted = true;
1569
- startRecoveredTreeObservers();
1570
- }
1571
-
1572
746
  function workspaceTreeSnapshot(workspaceId, options = {}) {
1573
747
  return require('./tree-cloud').workspaceTreeSnapshot(workspaceId, options);
1574
748
  }
@@ -1609,8 +783,6 @@ module.exports = {
1609
783
  reconcileRegisteredWorkspaceTrees,
1610
784
  reconcileWorkspaceTree,
1611
785
  requirePathCapability,
1612
- scanWorkspacePhysicalAliases,
1613
- verifiedWorkspaceRoot,
1614
786
  workspaceTreeSnapshot,
1615
787
  workspaceForPath,
1616
788
  };