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.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- 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 +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- 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
|
|
297
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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(
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
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 (
|
|
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
|
|
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
|
|
1293
|
-
if (!
|
|
1294
|
-
|
|
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
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
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.
|
|
683
|
+
kind: 'tree.create',
|
|
1495
684
|
workspaceId: workspace.id,
|
|
1496
|
-
nodeId:
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
};
|