amalgm 0.1.161 → 0.1.162
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 +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { openLocalDb } = require('../state/db');
|
|
7
|
+
const { applyTreeOperation } = require('./tree/reducer');
|
|
8
|
+
const { can } = require('./tree/access');
|
|
9
|
+
const { relativeWorkspacePath, shouldEnroll, shouldSync } = require('./tree/inclusion');
|
|
10
|
+
const {
|
|
11
|
+
hasAncestorInSet,
|
|
12
|
+
materializeOperation,
|
|
13
|
+
persistNodes,
|
|
14
|
+
relativePathForNode,
|
|
15
|
+
rowToNode,
|
|
16
|
+
workspaceRow,
|
|
17
|
+
} = require('./tree/local-state');
|
|
18
|
+
const { normalizeTreeOperation } = require('./tree/operations');
|
|
19
|
+
const { uuidV7 } = require('./tree/identity');
|
|
20
|
+
|
|
21
|
+
const TREE_RESOURCE_PREFIX = 'workspace-tree:';
|
|
22
|
+
|
|
23
|
+
function invalid(message, statusCode = 400) {
|
|
24
|
+
return Object.assign(new Error(message), { statusCode });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function workspaceForPath(absolutePath, database = openLocalDb()) {
|
|
28
|
+
const resolved = path.resolve(absolutePath);
|
|
29
|
+
return database.prepare(`
|
|
30
|
+
SELECT * FROM workspaces
|
|
31
|
+
WHERE path IS NOT NULL
|
|
32
|
+
AND (? = path OR substr(?, 1, length(path) + 1) = path || ?)
|
|
33
|
+
ORDER BY length(path) DESC
|
|
34
|
+
LIMIT 1
|
|
35
|
+
`).get(resolved, resolved, path.sep) || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listWorkspaceNodes(workspaceId, options = {}) {
|
|
39
|
+
const database = options.database || openLocalDb();
|
|
40
|
+
const clauses = ['workspace_id = ?'];
|
|
41
|
+
if (options.includeMissing !== true) clauses.push('missing_at IS NULL');
|
|
42
|
+
if (options.includePurged !== true) clauses.push('purged_at IS NULL');
|
|
43
|
+
if (options.includeTrashed !== true) clauses.push('trashed_at IS NULL');
|
|
44
|
+
return database.prepare(`
|
|
45
|
+
SELECT * FROM workspace_nodes
|
|
46
|
+
WHERE ${clauses.join(' AND ')}
|
|
47
|
+
ORDER BY length(relative_path), relative_path
|
|
48
|
+
`).all(workspaceId).map(rowToNode);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function nodeForPath(absolutePath, options = {}) {
|
|
52
|
+
const database = options.database || openLocalDb();
|
|
53
|
+
const workspace = workspaceForPath(absolutePath, database);
|
|
54
|
+
if (!workspace) return null;
|
|
55
|
+
const relativePath = path.resolve(absolutePath) === path.resolve(workspace.path)
|
|
56
|
+
? ''
|
|
57
|
+
: relativeWorkspacePath(workspace.path, path.resolve(absolutePath));
|
|
58
|
+
if (relativePath == null) return null;
|
|
59
|
+
const row = database.prepare(`
|
|
60
|
+
SELECT * FROM workspace_nodes
|
|
61
|
+
WHERE workspace_id = ? AND relative_path = ?
|
|
62
|
+
AND missing_at IS NULL AND purged_at IS NULL AND trashed_at IS NULL
|
|
63
|
+
LIMIT 1
|
|
64
|
+
`).get(workspace.id, relativePath);
|
|
65
|
+
const node = rowToNode(row);
|
|
66
|
+
return node ? { workspace, node } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function absolutePathForNode(nodeId, options = {}) {
|
|
70
|
+
const database = options.database || openLocalDb();
|
|
71
|
+
const row = database.prepare(`
|
|
72
|
+
SELECT workspace_nodes.*, workspaces.path AS workspace_path
|
|
73
|
+
FROM workspace_nodes
|
|
74
|
+
JOIN workspaces ON workspaces.id = workspace_nodes.workspace_id
|
|
75
|
+
WHERE workspace_nodes.id = ?
|
|
76
|
+
`).get(nodeId);
|
|
77
|
+
if (!row || row.purged_at || row.missing_at) return null;
|
|
78
|
+
return row.relative_path
|
|
79
|
+
? path.join(row.workspace_path, ...row.relative_path.split('/'))
|
|
80
|
+
: row.workspace_path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function statIdentity(stats) {
|
|
84
|
+
return { deviceId: String(stats.dev), inode: String(stats.ino) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function scanDirectory(rootPath) {
|
|
88
|
+
const entries = [];
|
|
89
|
+
const visit = (directory, parentRelativePath) => {
|
|
90
|
+
const children = fs.readdirSync(directory, { withFileTypes: true })
|
|
91
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
92
|
+
for (const child of children) {
|
|
93
|
+
const relativePath = parentRelativePath ? `${parentRelativePath}/${child.name}` : child.name;
|
|
94
|
+
if (!shouldEnroll(relativePath)) continue;
|
|
95
|
+
const absolutePath = path.join(directory, child.name);
|
|
96
|
+
const stats = fs.lstatSync(absolutePath);
|
|
97
|
+
const kind = stats.isSymbolicLink() ? 'symlink' : stats.isDirectory() ? 'folder' : 'file';
|
|
98
|
+
entries.push({
|
|
99
|
+
relativePath,
|
|
100
|
+
parentRelativePath,
|
|
101
|
+
name: child.name,
|
|
102
|
+
kind,
|
|
103
|
+
symlinkTarget: kind === 'symlink' ? fs.readlinkSync(absolutePath) : null,
|
|
104
|
+
...statIdentity(stats),
|
|
105
|
+
});
|
|
106
|
+
if (kind === 'folder') visit(absolutePath, relativePath);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
visit(rootPath, '');
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Enroll current disk state without turning a path into identity. Existing
|
|
115
|
+
* paths retain their UUID; a same-filesystem rename is recognized by inode;
|
|
116
|
+
* new nodes receive UUIDv7. Missing nodes are held as missing, never silently
|
|
117
|
+
* deleted, so an offline or transient filesystem gap cannot erase history.
|
|
118
|
+
*/
|
|
119
|
+
function enrollWorkspace(identifier, options = {}) {
|
|
120
|
+
const database = options.database || openLocalDb();
|
|
121
|
+
const workspace = workspaceRow(identifier, database);
|
|
122
|
+
if (!workspace) throw invalid('workspace not found', 404);
|
|
123
|
+
const rootPath = path.resolve(workspace.path || '');
|
|
124
|
+
const rootStats = fs.statSync(rootPath);
|
|
125
|
+
if (!rootStats.isDirectory()) throw invalid('workspace path is not a folder', 409);
|
|
126
|
+
|
|
127
|
+
const scanned = scanDirectory(rootPath);
|
|
128
|
+
const existing = database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id);
|
|
129
|
+
const byPath = new Map(existing.map((row) => [row.relative_path, row]));
|
|
130
|
+
const byIdentity = new Map();
|
|
131
|
+
for (const row of existing) {
|
|
132
|
+
if (!row.device_id || !row.inode || row.purged_at) continue;
|
|
133
|
+
const key = `${row.device_id}:${row.inode}`;
|
|
134
|
+
const matches = byIdentity.get(key) || [];
|
|
135
|
+
matches.push(row);
|
|
136
|
+
byIdentity.set(key, matches);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const nodeIdByPath = new Map([['', workspace.id]]);
|
|
140
|
+
const seenIds = new Set([workspace.id]);
|
|
141
|
+
const renamed = [];
|
|
142
|
+
let created = 0;
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
|
|
145
|
+
database.transaction(() => {
|
|
146
|
+
database.prepare(`
|
|
147
|
+
UPDATE workspace_nodes
|
|
148
|
+
SET name = ?, device_id = ?, inode = ?, missing_at = NULL, updated_at = ?
|
|
149
|
+
WHERE id = ?
|
|
150
|
+
`).run(workspace.name, String(rootStats.dev), String(rootStats.ino), now, workspace.id);
|
|
151
|
+
|
|
152
|
+
for (const entry of scanned) {
|
|
153
|
+
const parentId = nodeIdByPath.get(entry.parentRelativePath);
|
|
154
|
+
if (!parentId) throw invalid(`missing enrolled parent for ${entry.relativePath}`, 500);
|
|
155
|
+
let row = byPath.get(entry.relativePath) || null;
|
|
156
|
+
if (!row) {
|
|
157
|
+
const inodeMatches = byIdentity.get(`${entry.deviceId}:${entry.inode}`) || [];
|
|
158
|
+
const eligible = inodeMatches.filter((candidate) => !seenIds.has(candidate.id));
|
|
159
|
+
if (eligible.length === 1) row = eligible[0];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const id = row?.id || uuidV7();
|
|
163
|
+
nodeIdByPath.set(entry.relativePath, id);
|
|
164
|
+
seenIds.add(id);
|
|
165
|
+
if (row) {
|
|
166
|
+
if (row.relative_path !== entry.relativePath) {
|
|
167
|
+
renamed.push({
|
|
168
|
+
nodeId: id,
|
|
169
|
+
oldPath: path.join(rootPath, ...row.relative_path.split('/')),
|
|
170
|
+
newPath: path.join(rootPath, ...entry.relativePath.split('/')),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
database.prepare(`
|
|
174
|
+
UPDATE workspace_nodes
|
|
175
|
+
SET parent_id = ?, kind = ?, name = ?, relative_path = ?,
|
|
176
|
+
symlink_target = ?, device_id = ?, inode = ?, missing_at = NULL,
|
|
177
|
+
updated_at = ?
|
|
178
|
+
WHERE id = ?
|
|
179
|
+
`).run(
|
|
180
|
+
parentId,
|
|
181
|
+
entry.kind,
|
|
182
|
+
entry.name,
|
|
183
|
+
entry.relativePath,
|
|
184
|
+
entry.symlinkTarget,
|
|
185
|
+
entry.deviceId,
|
|
186
|
+
entry.inode,
|
|
187
|
+
now,
|
|
188
|
+
id,
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
database.prepare(`
|
|
192
|
+
INSERT INTO workspace_nodes (
|
|
193
|
+
id, workspace_id, parent_id, kind, name, relative_path,
|
|
194
|
+
symlink_target, device_id, inode, created_at, updated_at
|
|
195
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
196
|
+
`).run(
|
|
197
|
+
id,
|
|
198
|
+
workspace.id,
|
|
199
|
+
parentId,
|
|
200
|
+
entry.kind,
|
|
201
|
+
entry.name,
|
|
202
|
+
entry.relativePath,
|
|
203
|
+
entry.symlinkTarget,
|
|
204
|
+
entry.deviceId,
|
|
205
|
+
entry.inode,
|
|
206
|
+
now,
|
|
207
|
+
now,
|
|
208
|
+
);
|
|
209
|
+
created += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const markMissing = database.prepare(`
|
|
214
|
+
UPDATE workspace_nodes SET missing_at = ?, updated_at = ?
|
|
215
|
+
WHERE workspace_id = ? AND id = ? AND purged_at IS NULL AND trashed_at IS NULL
|
|
216
|
+
`);
|
|
217
|
+
for (const row of existing) {
|
|
218
|
+
if (row.id !== workspace.id && !seenIds.has(row.id)) {
|
|
219
|
+
markMissing.run(now, now, workspace.id, row.id);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
|
|
224
|
+
if (renamed.length) {
|
|
225
|
+
try {
|
|
226
|
+
require('../state/docs').rebindNodePaths(renamed);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.warn('[WorkspaceTree] live document rebind after scan failed:', error?.message || error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { workspaceId: workspace.id, created, renamed: renamed.length, nodes: listWorkspaceNodes(workspace.id, { database }) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function lstatOrNull(targetPath) {
|
|
235
|
+
try {
|
|
236
|
+
return fs.lstatSync(targetPath);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error?.code === 'ENOENT') return null;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function capabilityForOperation(kind) {
|
|
244
|
+
return {
|
|
245
|
+
'tree.create': 'create',
|
|
246
|
+
'tree.rename': 'rename',
|
|
247
|
+
'tree.move': 'move',
|
|
248
|
+
'tree.trash': 'trash',
|
|
249
|
+
'tree.restore': 'restore',
|
|
250
|
+
'tree.purge': 'purge',
|
|
251
|
+
}[kind];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function requirePathCapability(pathInput, capability, options = {}) {
|
|
255
|
+
const database = options.database || openLocalDb();
|
|
256
|
+
const targetPath = path.resolve(pathInput);
|
|
257
|
+
const workspace = workspaceForPath(targetPath, database);
|
|
258
|
+
if (!workspace) return null;
|
|
259
|
+
|
|
260
|
+
let currentPath = targetPath;
|
|
261
|
+
let found = null;
|
|
262
|
+
while (!found) {
|
|
263
|
+
found = nodeForPath(currentPath, { database });
|
|
264
|
+
if (found || currentPath === workspace.path) break;
|
|
265
|
+
const parentPath = path.dirname(currentPath);
|
|
266
|
+
if (parentPath === currentPath) break;
|
|
267
|
+
currentPath = parentPath;
|
|
268
|
+
}
|
|
269
|
+
if (!found) throw invalid('workspace root is not enrolled', 409);
|
|
270
|
+
const role = options.role || require('./access-store').currentUserRole(found.node.id, { database });
|
|
271
|
+
if (!can(role, capability)) throw invalid('workspace path operation is forbidden', 403);
|
|
272
|
+
return { ...found, role };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function commitTreeOperation(input, options = {}) {
|
|
276
|
+
let operation = normalizeTreeOperation(input);
|
|
277
|
+
const database = options.database || openLocalDb();
|
|
278
|
+
const accessNodeId = operation.kind === 'tree.create' ? operation.parentId : operation.nodeId;
|
|
279
|
+
const role = options.role || require('./access-store').currentUserRole(accessNodeId, { database });
|
|
280
|
+
if (!can(role, capabilityForOperation(operation.kind))) throw invalid('tree operation is forbidden', 403);
|
|
281
|
+
const workspace = workspaceRow(operation.workspaceId, database);
|
|
282
|
+
if (!workspace) throw invalid('workspace not found', 404);
|
|
283
|
+
const before = new Map(
|
|
284
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
|
|
285
|
+
.map(rowToNode)
|
|
286
|
+
.map((node) => [node.id, node]),
|
|
287
|
+
);
|
|
288
|
+
if (operation.kind === 'tree.move' && !operation.previousParentId) {
|
|
289
|
+
operation = { ...operation, previousParentId: before.get(operation.nodeId)?.parentId || null };
|
|
290
|
+
}
|
|
291
|
+
const after = applyTreeOperation(before, operation);
|
|
292
|
+
const mutationId = options.mutationId || `mut_${crypto.randomBytes(16).toString('base64url')}`;
|
|
293
|
+
const localResource = `${TREE_RESOURCE_PREFIX}${workspace.id}`;
|
|
294
|
+
const targetBefore = before.get(operation.nodeId);
|
|
295
|
+
const targetAfter = after.get(operation.nodeId);
|
|
296
|
+
const targetRelativePath = targetBefore
|
|
297
|
+
? relativePathForNode(before, targetBefore.id)
|
|
298
|
+
: targetAfter ? relativePathForNode(after, targetAfter.id) : '';
|
|
299
|
+
const replica = shouldSync(targetRelativePath)
|
|
300
|
+
? require('../state/replicas').getReplicaByLocalResource(localResource, { database })
|
|
301
|
+
: null;
|
|
302
|
+
let paths = null;
|
|
303
|
+
|
|
304
|
+
const result = require('../state/mutations').commitLocalMutation({
|
|
305
|
+
channelId: replica ? `resource:${replica.resourceId}` : localResource,
|
|
306
|
+
localResource,
|
|
307
|
+
sharedResourceId: replica?.resourceId,
|
|
308
|
+
mutationId,
|
|
309
|
+
deviceId: replica ? process.env.AMALGM_DEVICE_ID : null,
|
|
310
|
+
authorityEpoch: replica?.authorityEpoch || 0,
|
|
311
|
+
baseCloudVersion: replica?.appliedVersion || 0,
|
|
312
|
+
contract: 'workspace-tree@1',
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
operationKind: operation.kind,
|
|
315
|
+
operation,
|
|
316
|
+
}, {
|
|
317
|
+
database,
|
|
318
|
+
materialize() {
|
|
319
|
+
if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
|
|
320
|
+
const affectedNodeIds = new Set();
|
|
321
|
+
for (const node of before.values()) {
|
|
322
|
+
let current = node.id;
|
|
323
|
+
const seen = new Set();
|
|
324
|
+
while (current && !seen.has(current)) {
|
|
325
|
+
seen.add(current);
|
|
326
|
+
if (current === operation.nodeId) {
|
|
327
|
+
affectedNodeIds.add(node.id);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
current = before.get(current)?.parentId || null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
require('../state/docs').closeDocsByNodeIds(affectedNodeIds);
|
|
334
|
+
}
|
|
335
|
+
paths = options.diskAlreadyApplied === true
|
|
336
|
+
? { oldPath: null, newPath: null }
|
|
337
|
+
: materializeOperation(workspace, before, after, operation);
|
|
338
|
+
database.transaction(() => {
|
|
339
|
+
paths.changedPaths = persistNodes(database, before, after, workspace.id);
|
|
340
|
+
})();
|
|
341
|
+
},
|
|
342
|
+
event: () => ({
|
|
343
|
+
resource: localResource,
|
|
344
|
+
op: 'update',
|
|
345
|
+
id: operation.nodeId,
|
|
346
|
+
patch: operation,
|
|
347
|
+
source: options.source || 'workspace-tree:local',
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const changedPaths = paths?.changedPaths || [];
|
|
352
|
+
if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
|
|
353
|
+
return { operation, mutation: result.mutation, event: result.event, paths };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function commitPathRename(oldPathInput, newPathInput, options = {}) {
|
|
357
|
+
const database = options.database || openLocalDb();
|
|
358
|
+
let found = nodeForPath(oldPathInput, { database });
|
|
359
|
+
if (!found) {
|
|
360
|
+
const workspace = workspaceForPath(path.resolve(oldPathInput), database);
|
|
361
|
+
const relative = workspace ? relativeWorkspacePath(workspace.path, path.resolve(oldPathInput)) : null;
|
|
362
|
+
const stats = lstatOrNull(path.resolve(oldPathInput));
|
|
363
|
+
if (workspace && relative && shouldEnroll(relative) && stats) {
|
|
364
|
+
const nodeKind = stats.isSymbolicLink() ? 'symlink' : stats.isDirectory() ? 'folder' : 'file';
|
|
365
|
+
commitPathCreate(oldPathInput, nodeKind, {
|
|
366
|
+
...options,
|
|
367
|
+
database,
|
|
368
|
+
symlinkTarget: nodeKind === 'symlink' ? fs.readlinkSync(path.resolve(oldPathInput)) : undefined,
|
|
369
|
+
});
|
|
370
|
+
found = nodeForPath(oldPathInput, { database });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!found) {
|
|
374
|
+
requirePathCapability(oldPathInput, 'rename', { ...options, database });
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const newPath = path.resolve(newPathInput);
|
|
378
|
+
const relative = relativeWorkspacePath(found.workspace.path, newPath);
|
|
379
|
+
if (!relative || !shouldEnroll(relative)) throw invalid('rename destination must stay inside the workspace', 409);
|
|
380
|
+
const parentPath = path.dirname(newPath);
|
|
381
|
+
let parent = nodeForPath(parentPath, { database });
|
|
382
|
+
if (!parent) {
|
|
383
|
+
commitPathCreate(parentPath, 'folder', { ...options, database });
|
|
384
|
+
parent = nodeForPath(parentPath, { database });
|
|
385
|
+
}
|
|
386
|
+
if (!parent || parent.workspace.id !== found.workspace.id || parent.node.kind !== 'folder') {
|
|
387
|
+
throw invalid('rename destination folder is not enrolled', 409);
|
|
388
|
+
}
|
|
389
|
+
const name = path.basename(newPath);
|
|
390
|
+
if (parent.node.id === found.node.parentId) {
|
|
391
|
+
return commitTreeOperation({
|
|
392
|
+
kind: 'tree.rename',
|
|
393
|
+
workspaceId: found.workspace.id,
|
|
394
|
+
nodeId: found.node.id,
|
|
395
|
+
name,
|
|
396
|
+
}, { ...options, database });
|
|
397
|
+
}
|
|
398
|
+
return commitTreeOperation({
|
|
399
|
+
kind: 'tree.move',
|
|
400
|
+
workspaceId: found.workspace.id,
|
|
401
|
+
nodeId: found.node.id,
|
|
402
|
+
parentId: parent.node.id,
|
|
403
|
+
name,
|
|
404
|
+
}, { ...options, database });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function commitPathCreate(pathInput, nodeKind, options = {}) {
|
|
408
|
+
const targetPath = path.resolve(pathInput);
|
|
409
|
+
const database = options.database || openLocalDb();
|
|
410
|
+
const workspace = workspaceForPath(targetPath, database);
|
|
411
|
+
if (!workspace) return null;
|
|
412
|
+
const existingNode = nodeForPath(targetPath, { database });
|
|
413
|
+
if (existingNode) return null;
|
|
414
|
+
|
|
415
|
+
const diskEntry = lstatOrNull(targetPath);
|
|
416
|
+
if (diskEntry) {
|
|
417
|
+
const diskKind = diskEntry.isSymbolicLink() ? 'symlink' : diskEntry.isDirectory() ? 'folder' : 'file';
|
|
418
|
+
if (diskKind !== nodeKind) throw invalid(`create destination is a ${diskKind}, not a ${nodeKind}`, 409);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const parentPath = path.dirname(targetPath);
|
|
422
|
+
let parent = nodeForPath(parentPath, { database });
|
|
423
|
+
if (!parent && parentPath !== targetPath && parentPath !== path.dirname(workspace.path)) {
|
|
424
|
+
const { nodeId: _nodeId, symlinkTarget: _symlinkTarget, ...parentOptions } = options;
|
|
425
|
+
commitPathCreate(parentPath, 'folder', { ...parentOptions, database });
|
|
426
|
+
parent = nodeForPath(parentPath, { database });
|
|
427
|
+
}
|
|
428
|
+
if (!parent || parent.workspace.id !== workspace.id || parent.node.kind !== 'folder') {
|
|
429
|
+
throw invalid('create destination folder is not enrolled', 409);
|
|
430
|
+
}
|
|
431
|
+
return commitTreeOperation({
|
|
432
|
+
kind: 'tree.create',
|
|
433
|
+
workspaceId: workspace.id,
|
|
434
|
+
nodeId: options.nodeId || uuidV7(),
|
|
435
|
+
parentId: parent.node.id,
|
|
436
|
+
nodeKind,
|
|
437
|
+
name: path.basename(targetPath),
|
|
438
|
+
symlinkTarget: options.symlinkTarget,
|
|
439
|
+
}, {
|
|
440
|
+
...options,
|
|
441
|
+
database,
|
|
442
|
+
diskAlreadyApplied: options.diskAlreadyApplied === true || Boolean(diskEntry),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function commitPathTrash(pathInput, options = {}) {
|
|
447
|
+
const database = options.database || openLocalDb();
|
|
448
|
+
let found = nodeForPath(pathInput, { database });
|
|
449
|
+
if (!found) {
|
|
450
|
+
const workspace = workspaceForPath(path.resolve(pathInput), database);
|
|
451
|
+
const relative = workspace ? relativeWorkspacePath(workspace.path, path.resolve(pathInput)) : null;
|
|
452
|
+
const stats = lstatOrNull(path.resolve(pathInput));
|
|
453
|
+
if (workspace && relative && shouldEnroll(relative) && stats) {
|
|
454
|
+
const nodeKind = stats.isSymbolicLink() ? 'symlink' : stats.isDirectory() ? 'folder' : 'file';
|
|
455
|
+
commitPathCreate(pathInput, nodeKind, {
|
|
456
|
+
...options,
|
|
457
|
+
database,
|
|
458
|
+
symlinkTarget: nodeKind === 'symlink' ? fs.readlinkSync(path.resolve(pathInput)) : undefined,
|
|
459
|
+
});
|
|
460
|
+
found = nodeForPath(pathInput, { database });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (!found) {
|
|
464
|
+
requirePathCapability(pathInput, 'trash', { ...options, database });
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
if (found.node.id === found.workspace.id) throw invalid('a workspace root cannot be trashed through the file API', 409);
|
|
468
|
+
return commitTreeOperation({
|
|
469
|
+
kind: 'tree.trash',
|
|
470
|
+
workspaceId: found.workspace.id,
|
|
471
|
+
nodeId: found.node.id,
|
|
472
|
+
}, { ...options, database });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function enrollRegisteredWorkspaces(options = {}) {
|
|
476
|
+
const database = options.database || openLocalDb();
|
|
477
|
+
const workspaces = database.prepare('SELECT id FROM workspaces ORDER BY created_at').all();
|
|
478
|
+
const results = [];
|
|
479
|
+
for (const workspace of workspaces) {
|
|
480
|
+
try {
|
|
481
|
+
const result = enrollWorkspace(workspace.id, { database });
|
|
482
|
+
ensureWorkspaceTreePromotion(workspace.id, { database });
|
|
483
|
+
results.push(result);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
console.warn(`[WorkspaceTree] enrollment failed for ${workspace.id}:`, error?.message || error);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return results;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Turn tree changes made by shells, editors, and agents directly on disk into
|
|
493
|
+
* the same durable operations as the REST file API. File bytes remain the
|
|
494
|
+
* document rail's job; this observer only reconciles identity and structure.
|
|
495
|
+
*/
|
|
496
|
+
function reconcileWorkspaceTree(identifier, options = {}) {
|
|
497
|
+
const database = options.database || openLocalDb();
|
|
498
|
+
const workspace = workspaceRow(identifier, database);
|
|
499
|
+
if (!workspace || !fs.existsSync(workspace.path)) return { operations: [] };
|
|
500
|
+
const operations = [];
|
|
501
|
+
const held = [];
|
|
502
|
+
const heldNodeIds = new Set();
|
|
503
|
+
const heldPaths = new Set();
|
|
504
|
+
const source = options.source || 'workspace-tree:observed';
|
|
505
|
+
|
|
506
|
+
const applyObserved = (operation) => {
|
|
507
|
+
try {
|
|
508
|
+
const result = commitTreeOperation(operation, { database, source, diskAlreadyApplied: true });
|
|
509
|
+
operations.push(result.operation);
|
|
510
|
+
return true;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error?.statusCode !== 403) throw error;
|
|
513
|
+
held.push({ operation, reason: 'forbidden' });
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const scanned = scanDirectory(workspace.path);
|
|
519
|
+
let rows = listWorkspaceNodes(workspace.id, {
|
|
520
|
+
database,
|
|
521
|
+
includeTrashed: true,
|
|
522
|
+
includeMissing: true,
|
|
523
|
+
});
|
|
524
|
+
const scannedByPath = new Map(scanned.map((entry) => [entry.relativePath, entry]));
|
|
525
|
+
const liveRows = rows.filter((node) => node.id !== workspace.id && !node.trashedAt && !node.purgedAt);
|
|
526
|
+
const liveByPath = new Map(liveRows.map((node) => [node.relativePath, node]));
|
|
527
|
+
const liveByInode = new Map();
|
|
528
|
+
for (const node of liveRows) {
|
|
529
|
+
if (!node.deviceId || !node.inode) continue;
|
|
530
|
+
const key = `${node.deviceId}:${node.inode}`;
|
|
531
|
+
const matches = liveByInode.get(key) || [];
|
|
532
|
+
matches.push(node);
|
|
533
|
+
liveByInode.set(key, matches);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const renamedIds = new Set();
|
|
537
|
+
for (const entry of scanned) {
|
|
538
|
+
if (liveByPath.has(entry.relativePath)) continue;
|
|
539
|
+
const matches = (liveByInode.get(`${entry.deviceId}:${entry.inode}`) || [])
|
|
540
|
+
.filter((node) => !renamedIds.has(node.id));
|
|
541
|
+
if (matches.length !== 1) continue;
|
|
542
|
+
const node = matches[0];
|
|
543
|
+
const rowsById = new Map(rows.map((item) => [item.id, item]));
|
|
544
|
+
if (hasAncestorInSet(node, rowsById, renamedIds)) continue;
|
|
545
|
+
const parent = nodeForPath(path.dirname(path.join(workspace.path, ...entry.relativePath.split('/'))), { database });
|
|
546
|
+
if (!parent) continue;
|
|
547
|
+
const applied = applyObserved(parent.node.id === node.parentId
|
|
548
|
+
? { kind: 'tree.rename', workspaceId: workspace.id, nodeId: node.id, name: entry.name }
|
|
549
|
+
: { kind: 'tree.move', workspaceId: workspace.id, nodeId: node.id, parentId: parent.node.id, name: entry.name });
|
|
550
|
+
if (!applied) {
|
|
551
|
+
heldNodeIds.add(node.id);
|
|
552
|
+
heldPaths.add(entry.relativePath);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
renamedIds.add(node.id);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
for (const entry of scanned) {
|
|
559
|
+
if (heldPaths.has(entry.relativePath)) continue;
|
|
560
|
+
if (nodeForPath(path.join(workspace.path, ...entry.relativePath.split('/')), { database })) continue;
|
|
561
|
+
const parent = nodeForPath(
|
|
562
|
+
entry.parentRelativePath
|
|
563
|
+
? path.join(workspace.path, ...entry.parentRelativePath.split('/'))
|
|
564
|
+
: workspace.path,
|
|
565
|
+
{ database },
|
|
566
|
+
);
|
|
567
|
+
if (!parent) continue;
|
|
568
|
+
applyObserved({
|
|
569
|
+
kind: 'tree.create',
|
|
570
|
+
workspaceId: workspace.id,
|
|
571
|
+
nodeId: uuidV7(),
|
|
572
|
+
parentId: parent.node.id,
|
|
573
|
+
nodeKind: entry.kind,
|
|
574
|
+
name: entry.name,
|
|
575
|
+
symlinkTarget: entry.symlinkTarget,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
rows = listWorkspaceNodes(workspace.id, { database });
|
|
580
|
+
const rowsById = new Map(rows.map((node) => [node.id, node]));
|
|
581
|
+
const missingIds = new Set(
|
|
582
|
+
rows
|
|
583
|
+
.filter((node) => node.id !== workspace.id && !scannedByPath.has(node.relativePath))
|
|
584
|
+
.filter((node) => !heldNodeIds.has(node.id))
|
|
585
|
+
.map((node) => node.id),
|
|
586
|
+
);
|
|
587
|
+
for (const node of rows) {
|
|
588
|
+
if (!missingIds.has(node.id) || hasAncestorInSet(node, rowsById, missingIds)) continue;
|
|
589
|
+
applyObserved({ kind: 'tree.trash', workspaceId: workspace.id, nodeId: node.id });
|
|
590
|
+
}
|
|
591
|
+
return { workspaceId: workspace.id, operations, held };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function reconcileRegisteredWorkspaceTrees(options = {}) {
|
|
595
|
+
const database = options.database || openLocalDb();
|
|
596
|
+
const results = [];
|
|
597
|
+
for (const workspace of database.prepare('SELECT id FROM workspaces ORDER BY created_at').all()) {
|
|
598
|
+
try {
|
|
599
|
+
results.push(reconcileWorkspaceTree(workspace.id, { database }));
|
|
600
|
+
} catch (error) {
|
|
601
|
+
console.warn(`[WorkspaceTree] observation failed for ${workspace.id}:`, error?.message || error);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return results;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
let enrollmentStarted = false;
|
|
608
|
+
function startTreeEnrollmentService() {
|
|
609
|
+
if (enrollmentStarted) return;
|
|
610
|
+
enrollmentStarted = true;
|
|
611
|
+
const timer = setTimeout(() => enrollRegisteredWorkspaces(), 0);
|
|
612
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
613
|
+
const observationTimer = setInterval(() => reconcileRegisteredWorkspaceTrees(), 2_000);
|
|
614
|
+
if (typeof observationTimer.unref === 'function') observationTimer.unref();
|
|
615
|
+
const promotionTimer = setInterval(() => {
|
|
616
|
+
try {
|
|
617
|
+
promoteEligibleWorkspaceTextFiles();
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.warn('[WorkspaceTree] text promotion pass failed:', error?.message || error);
|
|
620
|
+
}
|
|
621
|
+
}, 15_000);
|
|
622
|
+
if (typeof promotionTimer.unref === 'function') promotionTimer.unref();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function workspaceTreeSnapshot(workspaceId, options = {}) {
|
|
626
|
+
return require('./tree-cloud').workspaceTreeSnapshot(workspaceId, options);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function installCloudTreeSnapshot(pathInput, input, options = {}) {
|
|
630
|
+
return require('./tree-cloud').installCloudTreeSnapshot(pathInput, input, options);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function applyCloudTreeMutation(input, options = {}) {
|
|
634
|
+
return require('./tree-cloud').applyCloudTreeMutation(input, options);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function ensureWorkspaceTreePromotion(workspaceId, options = {}) {
|
|
638
|
+
return require('./tree-promotion').ensureWorkspaceTreePromotion(workspaceId, options);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function promoteEligibleWorkspaceTextFiles(options = {}) {
|
|
642
|
+
return require('./tree-promotion').promoteEligibleWorkspaceTextFiles(options);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
module.exports = {
|
|
646
|
+
TREE_RESOURCE_PREFIX,
|
|
647
|
+
absolutePathForNode,
|
|
648
|
+
commitPathCreate,
|
|
649
|
+
commitPathRename,
|
|
650
|
+
commitPathTrash,
|
|
651
|
+
commitTreeOperation,
|
|
652
|
+
enrollWorkspace,
|
|
653
|
+
enrollRegisteredWorkspaces,
|
|
654
|
+
ensureWorkspaceTreePromotion,
|
|
655
|
+
installCloudTreeSnapshot,
|
|
656
|
+
listWorkspaceNodes,
|
|
657
|
+
nodeForPath,
|
|
658
|
+
startTreeEnrollmentService,
|
|
659
|
+
applyCloudTreeMutation,
|
|
660
|
+
promoteEligibleWorkspaceTextFiles,
|
|
661
|
+
reconcileRegisteredWorkspaceTrees,
|
|
662
|
+
reconcileWorkspaceTree,
|
|
663
|
+
requirePathCapability,
|
|
664
|
+
workspaceTreeSnapshot,
|
|
665
|
+
workspaceForPath,
|
|
666
|
+
};
|