amalgm 0.1.239 → 0.1.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- 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
|
|
158
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
617
|
-
|
|
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 (
|
|
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
|
|
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
|
|
660
|
-
if (!
|
|
661
|
-
|
|
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
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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.
|
|
1494
|
+
kind: 'tree.trash',
|
|
684
1495
|
workspaceId: workspace.id,
|
|
685
|
-
nodeId:
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
() =>
|
|
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(
|
|
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
|
};
|