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
|
@@ -62,6 +62,10 @@ function ensureProjectMemory(workspace, source = 'workspace') {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function enrollWorkspaceTree(workspace) {
|
|
66
|
+
return require('./tree-store').enrollWorkspace(workspace.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
async function uniqueProjectPath(workspaceRoot, requestedName) {
|
|
66
70
|
const name = sanitizeProjectName(requestedName);
|
|
67
71
|
let candidate = path.join(workspaceRoot, name);
|
|
@@ -211,6 +215,7 @@ async function handleProjectRegister(body, sendJson) {
|
|
|
211
215
|
source: 'workspace:register',
|
|
212
216
|
});
|
|
213
217
|
ensureProjectMemory(workspace, 'workspace:register');
|
|
218
|
+
const tree = enrollWorkspaceTree(workspace);
|
|
214
219
|
|
|
215
220
|
sendJson(200, {
|
|
216
221
|
success: true,
|
|
@@ -219,6 +224,7 @@ async function handleProjectRegister(body, sendJson) {
|
|
|
219
224
|
name: workspace.name,
|
|
220
225
|
uri: workspace.uri,
|
|
221
226
|
workspaceRoot,
|
|
227
|
+
tree: { nodes: tree.nodes.length, created: tree.created },
|
|
222
228
|
});
|
|
223
229
|
} catch (error) {
|
|
224
230
|
sendJson(500, {
|
|
@@ -302,6 +308,7 @@ async function handleImport(body, sendJson) {
|
|
|
302
308
|
source: 'workspace:import',
|
|
303
309
|
});
|
|
304
310
|
ensureProjectMemory(workspace, 'workspace:import');
|
|
311
|
+
const tree = enrollWorkspaceTree(workspace);
|
|
305
312
|
sendJson(200, {
|
|
306
313
|
success: true,
|
|
307
314
|
path: workspace.path,
|
|
@@ -309,6 +316,7 @@ async function handleImport(body, sendJson) {
|
|
|
309
316
|
workspace,
|
|
310
317
|
workspaceRoot,
|
|
311
318
|
alreadyInWorkspace: true,
|
|
319
|
+
tree: { nodes: tree.nodes.length, created: tree.created },
|
|
312
320
|
});
|
|
313
321
|
return;
|
|
314
322
|
}
|
|
@@ -333,6 +341,7 @@ async function handleImport(body, sendJson) {
|
|
|
333
341
|
source: 'workspace:import',
|
|
334
342
|
});
|
|
335
343
|
ensureProjectMemory(workspace, 'workspace:import');
|
|
344
|
+
const tree = enrollWorkspaceTree(workspace);
|
|
336
345
|
|
|
337
346
|
sendJson(200, {
|
|
338
347
|
success: true,
|
|
@@ -341,6 +350,7 @@ async function handleImport(body, sendJson) {
|
|
|
341
350
|
workspace,
|
|
342
351
|
workspaceRoot,
|
|
343
352
|
alreadyInWorkspace: false,
|
|
353
|
+
tree: { nodes: tree.nodes.length, created: tree.created },
|
|
344
354
|
});
|
|
345
355
|
} catch (error) {
|
|
346
356
|
sendJson(500, {
|
|
@@ -380,7 +390,15 @@ async function handleClone(body, sendJson) {
|
|
|
380
390
|
source: 'workspace:clone',
|
|
381
391
|
});
|
|
382
392
|
ensureProjectMemory(workspace, 'workspace:clone');
|
|
383
|
-
|
|
393
|
+
const tree = enrollWorkspaceTree(workspace);
|
|
394
|
+
sendJson(200, {
|
|
395
|
+
success: true,
|
|
396
|
+
path: workspace.path,
|
|
397
|
+
workspace,
|
|
398
|
+
workspaceRoot,
|
|
399
|
+
alreadyExists: true,
|
|
400
|
+
tree: { nodes: tree.nodes.length, created: tree.created },
|
|
401
|
+
});
|
|
384
402
|
return;
|
|
385
403
|
}
|
|
386
404
|
sendJson(409, {
|
|
@@ -416,8 +434,16 @@ async function handleClone(body, sendJson) {
|
|
|
416
434
|
source: 'workspace:clone',
|
|
417
435
|
});
|
|
418
436
|
ensureProjectMemory(workspace, 'workspace:clone');
|
|
437
|
+
const tree = enrollWorkspaceTree(workspace);
|
|
419
438
|
|
|
420
|
-
sendJson(200, {
|
|
439
|
+
sendJson(200, {
|
|
440
|
+
success: true,
|
|
441
|
+
path: workspace.path,
|
|
442
|
+
workspace,
|
|
443
|
+
workspaceRoot,
|
|
444
|
+
alreadyExists: false,
|
|
445
|
+
tree: { nodes: tree.nodes.length, created: tree.created },
|
|
446
|
+
});
|
|
421
447
|
}
|
|
422
448
|
|
|
423
449
|
async function handleBranches(query, sendJson) {
|
|
@@ -654,6 +680,8 @@ function handleWorkspaceSyncStats(sendJson) {
|
|
|
654
680
|
cards: { total: cards.length, ...health },
|
|
655
681
|
service: repoCardServiceStats(),
|
|
656
682
|
wall: wallStats(),
|
|
683
|
+
checkpoints: require('./checkpoint').checkpointStats(),
|
|
684
|
+
reconciliation: require('./reconcile').reconciliationStats(),
|
|
657
685
|
});
|
|
658
686
|
} catch (error) {
|
|
659
687
|
sendJson(500, { error: error instanceof Error ? error.message : 'Failed to read sync stats' });
|
|
@@ -685,6 +713,150 @@ function handleWorkspaceMachines(sendJson) {
|
|
|
685
713
|
}
|
|
686
714
|
}
|
|
687
715
|
|
|
716
|
+
function sendWorkspaceDomainError(error, sendJson) {
|
|
717
|
+
sendJson(Number(error?.statusCode) || 500, {
|
|
718
|
+
error: error instanceof Error ? error.message : 'Workspace operation failed',
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function handleTreeList(query, sendJson) {
|
|
723
|
+
try {
|
|
724
|
+
const pathInput = query?.path;
|
|
725
|
+
if (typeof pathInput === 'string' && pathInput) {
|
|
726
|
+
const found = require('./tree-store').nodeForPath(pathInput);
|
|
727
|
+
if (!found) throw Object.assign(new Error('workspace item not found'), { statusCode: 404 });
|
|
728
|
+
const actorUserId = require('./identity').userId();
|
|
729
|
+
sendJson(200, {
|
|
730
|
+
workspaceId: found.workspace.id,
|
|
731
|
+
workspace: found.workspace,
|
|
732
|
+
node: found.node,
|
|
733
|
+
access: actorUserId
|
|
734
|
+
? require('./access-store').resolveAccess(found.node.id, actorUserId)
|
|
735
|
+
: null,
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const workspaceId = query?.workspaceId || query?.workspace_id;
|
|
740
|
+
if (typeof workspaceId !== 'string' || !workspaceId) throw Object.assign(new Error('workspaceId is required'), { statusCode: 400 });
|
|
741
|
+
sendJson(200, {
|
|
742
|
+
workspaceId,
|
|
743
|
+
nodes: require('./tree-store').listWorkspaceNodes(workspaceId, {
|
|
744
|
+
includeTrashed: query?.includeTrashed === 'true',
|
|
745
|
+
includePurged: query?.includePurged === 'true',
|
|
746
|
+
includeMissing: query?.includeMissing === 'true',
|
|
747
|
+
}),
|
|
748
|
+
});
|
|
749
|
+
} catch (error) {
|
|
750
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function handleTreeEnroll(body, sendJson) {
|
|
755
|
+
try {
|
|
756
|
+
sendJson(200, require('./tree-store').enrollWorkspace(body?.workspaceId || body?.path));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function handleTreeMutation(body, sendJson) {
|
|
763
|
+
try {
|
|
764
|
+
const actorUserId = require('./identity').userId();
|
|
765
|
+
if (!actorUserId) throw Object.assign(new Error('this runtime is not enrolled to a user'), { statusCode: 401 });
|
|
766
|
+
const operation = body?.operation || body;
|
|
767
|
+
const accessNodeId = operation?.kind === 'tree.create' ? operation?.parentId : operation?.nodeId;
|
|
768
|
+
const access = require('./access-store').resolveAccess(accessNodeId, actorUserId);
|
|
769
|
+
if (!access?.role) throw Object.assign(new Error('tree operation is forbidden'), { statusCode: 403 });
|
|
770
|
+
sendJson(200, require('./tree-store').commitTreeOperation(operation, {
|
|
771
|
+
mutationId: body?.mutationId,
|
|
772
|
+
role: access.role,
|
|
773
|
+
source: 'workspace-tree:api',
|
|
774
|
+
}));
|
|
775
|
+
} catch (error) {
|
|
776
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function handleCloudWorkspaceAttachment(body, sendJson) {
|
|
781
|
+
let targetPath = null;
|
|
782
|
+
try {
|
|
783
|
+
const resourceId = typeof body?.workspaceId === 'string' ? body.workspaceId.toLowerCase() : '';
|
|
784
|
+
if (!require('./tree/identity').isUuid(resourceId)) {
|
|
785
|
+
throw Object.assign(new Error('workspaceId must be a UUID'), { statusCode: 400 });
|
|
786
|
+
}
|
|
787
|
+
const existing = require('../state/replicas').getReplicaByResourceId(resourceId);
|
|
788
|
+
if (existing) {
|
|
789
|
+
sendJson(200, { attached: true, duplicate: true, replica: existing });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const workspaceRoot = await ensureWorkspaceRoot();
|
|
793
|
+
targetPath = await uniqueProjectPath(workspaceRoot, body?.name || 'Shared workspace');
|
|
794
|
+
await fs.promises.mkdir(targetPath, { recursive: false });
|
|
795
|
+
const attachment = require('../state/attachments').enqueueAttachment({
|
|
796
|
+
attachmentId: `att_${require('crypto').randomBytes(16).toString('base64url')}`,
|
|
797
|
+
resourceId,
|
|
798
|
+
localPath: targetPath,
|
|
799
|
+
}).attachment;
|
|
800
|
+
sendJson(202, { attached: false, duplicate: false, path: targetPath, attachment });
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (targetPath) {
|
|
803
|
+
try { await fs.promises.rmdir(targetPath); } catch { /* Preserve a non-empty or externally-touched folder. */ }
|
|
804
|
+
}
|
|
805
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function handleAccessRead(query, sendJson) {
|
|
810
|
+
try {
|
|
811
|
+
const actorUserId = require('./identity').userId();
|
|
812
|
+
const userId = query?.userId || actorUserId;
|
|
813
|
+
if (!actorUserId || !userId) throw Object.assign(new Error('userId is required'), { statusCode: 400 });
|
|
814
|
+
sendJson(200, { access: require('./access-store').resolveAccess(query?.nodeId, userId) });
|
|
815
|
+
} catch (error) {
|
|
816
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function handleAccessSet(body, sendJson) {
|
|
821
|
+
try {
|
|
822
|
+
const actorUserId = require('./identity').userId();
|
|
823
|
+
sendJson(200, require('./access-store').setNodePermission({
|
|
824
|
+
actorUserId,
|
|
825
|
+
nodeId: body?.nodeId,
|
|
826
|
+
userId: body?.userId,
|
|
827
|
+
role: body?.role,
|
|
828
|
+
}));
|
|
829
|
+
} catch (error) {
|
|
830
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function handleAccessClear(body, sendJson) {
|
|
835
|
+
try {
|
|
836
|
+
const actorUserId = require('./identity').userId();
|
|
837
|
+
sendJson(200, require('./access-store').clearNodePermission({
|
|
838
|
+
actorUserId,
|
|
839
|
+
nodeId: body?.nodeId,
|
|
840
|
+
userId: body?.userId,
|
|
841
|
+
}));
|
|
842
|
+
} catch (error) {
|
|
843
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function handleAccessRevokeUser(body, sendJson) {
|
|
848
|
+
try {
|
|
849
|
+
const actorUserId = require('./identity').userId();
|
|
850
|
+
sendJson(200, require('./access-store').revokeWorkspaceUser({
|
|
851
|
+
actorUserId,
|
|
852
|
+
workspaceId: body?.workspaceId,
|
|
853
|
+
userId: body?.userId,
|
|
854
|
+
}));
|
|
855
|
+
} catch (error) {
|
|
856
|
+
sendWorkspaceDomainError(error, sendJson);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
688
860
|
module.exports = {
|
|
689
861
|
getWorkspaceRoot,
|
|
690
862
|
handleProjects,
|
|
@@ -703,4 +875,12 @@ module.exports = {
|
|
|
703
875
|
handleRepoCardsRefresh,
|
|
704
876
|
handleWorkspaceMachines,
|
|
705
877
|
handleWorkspaceSyncStats,
|
|
878
|
+
handleTreeList,
|
|
879
|
+
handleTreeEnroll,
|
|
880
|
+
handleTreeMutation,
|
|
881
|
+
handleCloudWorkspaceAttachment,
|
|
882
|
+
handleAccessRead,
|
|
883
|
+
handleAccessSet,
|
|
884
|
+
handleAccessClear,
|
|
885
|
+
handleAccessRevokeUser,
|
|
706
886
|
};
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const crypto = require('crypto');
|
|
4
3
|
const fs = require('fs');
|
|
5
4
|
const path = require('path');
|
|
6
5
|
const { AMALGM_COMPUTER_ID } = require('../config');
|
|
@@ -10,6 +9,7 @@ const {
|
|
|
10
9
|
canonicalProjectPath,
|
|
11
10
|
isLegacyManagedWorkspacePath,
|
|
12
11
|
} = require('../../lib/project-paths');
|
|
12
|
+
const { isUuidV7, uuidV7 } = require('./tree/identity');
|
|
13
13
|
|
|
14
14
|
function cleanString(value) {
|
|
15
15
|
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
@@ -31,10 +31,6 @@ function workspaceUriForPath(localPath, computerId) {
|
|
|
31
31
|
return `computer://${encodeURIComponent(stableComputerId(computerId))}${encodePathForUri(normalizedPath)}`;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function workspaceIdForUri(uri) {
|
|
35
|
-
return `ws_${crypto.createHash('sha256').update(uri).digest('hex').slice(0, 20)}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
34
|
function isWithin(root, target) {
|
|
39
35
|
const relative = path.relative(root, target);
|
|
40
36
|
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
@@ -73,7 +69,8 @@ function normalizeWorkspaceRecord(input, options = {}) {
|
|
|
73
69
|
const resolvedPath = canonicalProjectPath(localPath);
|
|
74
70
|
const computerId = stableComputerId(input.computerId || input.computer_id || options.computerId);
|
|
75
71
|
const uri = cleanString(input.uri) || workspaceUriForPath(resolvedPath, computerId);
|
|
76
|
-
const
|
|
72
|
+
const requestedId = cleanString(input.id);
|
|
73
|
+
const id = isUuidV7(requestedId) ? requestedId.toLowerCase() : uuidV7();
|
|
77
74
|
const createdAt = cleanString(input.createdAt || input.created_at) || nowIso();
|
|
78
75
|
const updatedAt = nowIso();
|
|
79
76
|
const managed = input.managed !== undefined
|
|
@@ -104,6 +101,41 @@ function normalizeWorkspaceRecord(input, options = {}) {
|
|
|
104
101
|
};
|
|
105
102
|
}
|
|
106
103
|
|
|
104
|
+
function workspaceRow(database, identifier) {
|
|
105
|
+
return database.prepare(`
|
|
106
|
+
SELECT workspaces.*
|
|
107
|
+
FROM workspaces
|
|
108
|
+
LEFT JOIN workspace_id_aliases ON workspace_id_aliases.workspace_id = workspaces.id
|
|
109
|
+
WHERE workspaces.id = ? OR workspaces.path = ? OR workspace_id_aliases.alias_id = ?
|
|
110
|
+
LIMIT 1
|
|
111
|
+
`).get(identifier, identifier, identifier) || null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureWorkspaceIdentityRows(database, workspace, options = {}) {
|
|
115
|
+
const now = workspace.createdAt || nowIso();
|
|
116
|
+
database.prepare(`
|
|
117
|
+
INSERT INTO workspace_nodes (
|
|
118
|
+
id, workspace_id, parent_id, kind, name, relative_path,
|
|
119
|
+
created_at, updated_at
|
|
120
|
+
) VALUES (?, ?, NULL, 'folder', ?, '', ?, ?)
|
|
121
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
122
|
+
name = excluded.name,
|
|
123
|
+
updated_at = excluded.updated_at
|
|
124
|
+
`).run(workspace.id, workspace.id, workspace.name, now, workspace.updatedAt);
|
|
125
|
+
database.prepare(`
|
|
126
|
+
INSERT OR IGNORE INTO workspace_access_state(workspace_id, authority_epoch)
|
|
127
|
+
VALUES (?, 1)
|
|
128
|
+
`).run(workspace.id);
|
|
129
|
+
|
|
130
|
+
const ownerUserId = options.seedOwner === false ? null : require('./identity').userId();
|
|
131
|
+
if (ownerUserId) {
|
|
132
|
+
database.prepare(`
|
|
133
|
+
INSERT OR IGNORE INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
|
|
134
|
+
VALUES (?, ?, ?, 'owner')
|
|
135
|
+
`).run(workspace.id, workspace.id, ownerUserId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
function rowToWorkspace(row) {
|
|
108
140
|
if (!row) return null;
|
|
109
141
|
const parsed = parseJson(row.workspace_json, {});
|
|
@@ -165,9 +197,17 @@ function upsertWorkspace(input, options = {}) {
|
|
|
165
197
|
|
|
166
198
|
db.transaction(() => {
|
|
167
199
|
workspace = normalizeWorkspaceRecord(input, options);
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
200
|
+
const requestedId = cleanString(input.id);
|
|
201
|
+
const existing = db.prepare(`
|
|
202
|
+
SELECT workspaces.*
|
|
203
|
+
FROM workspaces
|
|
204
|
+
LEFT JOIN workspace_id_aliases ON workspace_id_aliases.workspace_id = workspaces.id
|
|
205
|
+
WHERE workspaces.id = ?
|
|
206
|
+
OR workspaces.uri = ?
|
|
207
|
+
OR workspaces.path = ?
|
|
208
|
+
OR workspace_id_aliases.alias_id = ?
|
|
209
|
+
LIMIT 1
|
|
210
|
+
`).get(workspace.id, workspace.uri, workspace.path, requestedId);
|
|
171
211
|
if (existing) {
|
|
172
212
|
const old = rowToWorkspace(existing);
|
|
173
213
|
workspace = {
|
|
@@ -222,6 +262,14 @@ function upsertWorkspace(input, options = {}) {
|
|
|
222
262
|
JSON.stringify(workspace),
|
|
223
263
|
);
|
|
224
264
|
|
|
265
|
+
if (requestedId && requestedId !== workspace.id) {
|
|
266
|
+
db.prepare(`
|
|
267
|
+
INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
|
|
268
|
+
VALUES (?, ?)
|
|
269
|
+
`).run(requestedId, workspace.id);
|
|
270
|
+
}
|
|
271
|
+
ensureWorkspaceIdentityRows(db, workspace, options);
|
|
272
|
+
|
|
225
273
|
events = publishWorkspaceEvents(db, existing ? 'update' : 'insert', workspace, options.source || 'workspace:upsert');
|
|
226
274
|
})();
|
|
227
275
|
|
|
@@ -238,7 +286,7 @@ function renameWorkspace(identifier, name, options = {}) {
|
|
|
238
286
|
let events = [];
|
|
239
287
|
|
|
240
288
|
db.transaction(() => {
|
|
241
|
-
const row = db
|
|
289
|
+
const row = workspaceRow(db, identifier);
|
|
242
290
|
if (!row) throw new Error('Workspace not found');
|
|
243
291
|
workspace = {
|
|
244
292
|
...rowToWorkspace(row),
|
|
@@ -263,7 +311,7 @@ function removeWorkspace(identifier, options = {}) {
|
|
|
263
311
|
let events = [];
|
|
264
312
|
|
|
265
313
|
db.transaction(() => {
|
|
266
|
-
const row = db
|
|
314
|
+
const row = workspaceRow(db, identifier);
|
|
267
315
|
if (!row) return;
|
|
268
316
|
workspace = rowToWorkspace(row);
|
|
269
317
|
db.prepare('DELETE FROM workspaces WHERE id = ?').run(workspace.id);
|
|
@@ -280,6 +328,5 @@ module.exports = {
|
|
|
280
328
|
removeWorkspace,
|
|
281
329
|
renameWorkspace,
|
|
282
330
|
upsertWorkspace,
|
|
283
|
-
workspaceIdForUri,
|
|
284
331
|
workspaceUriForPath,
|
|
285
332
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Executed git movements — the one place the workspace layer changes a
|
|
5
|
+
* repository. `git.js` observes and never mutates; this module mutates
|
|
6
|
+
* and does nothing else, under the workspace-sync.md frozen boundaries:
|
|
7
|
+
* no shell, pinned cwd, no prompts, and config armor so repo-configured
|
|
8
|
+
* programs (hooks, fsmonitor) can never run under an executed transition.
|
|
9
|
+
*
|
|
10
|
+
* The verbs are deliberately few and exact — fetch one branch from a
|
|
11
|
+
* named remote, fast-forward to an exact commit, apply or reverse a
|
|
12
|
+
* captured patch. Anything messier is a human act on one machine; the
|
|
13
|
+
* rail carries its settled outcome. Every execution, success or failure,
|
|
14
|
+
* lands as one line in a visible journal before control returns.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { execFile } = require('child_process');
|
|
20
|
+
const { promisify } = require('util');
|
|
21
|
+
const { AMALGM_DIR } = require('../config');
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
const JOURNAL_FILE = path.join(AMALGM_DIR, 'state', 'workspace-transitions.jsonl');
|
|
26
|
+
|
|
27
|
+
const TRANSITION_ENV = {
|
|
28
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
29
|
+
LC_ALL: 'C',
|
|
30
|
+
GIT_CONFIG_COUNT: '2',
|
|
31
|
+
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
|
32
|
+
GIT_CONFIG_VALUE_0: '/dev/null',
|
|
33
|
+
GIT_CONFIG_KEY_1: 'core.fsmonitor',
|
|
34
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
class TransitionError extends Error {
|
|
38
|
+
constructor(action, worktreeRoot, stderr) {
|
|
39
|
+
super(`${action} in ${worktreeRoot} failed: ${stderr}`);
|
|
40
|
+
this.name = 'TransitionError';
|
|
41
|
+
this.action = action;
|
|
42
|
+
this.worktreeRoot = worktreeRoot;
|
|
43
|
+
this.stderr = stderr;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function journal(entry) {
|
|
48
|
+
try {
|
|
49
|
+
fs.mkdirSync(path.dirname(JOURNAL_FILE), { recursive: true });
|
|
50
|
+
fs.appendFileSync(JOURNAL_FILE, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn('[WorkspaceTransition] journal write failed:', error.message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function run(action, worktreeRoot, args, { timeoutMs = 120_000 } = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
59
|
+
cwd: worktreeRoot,
|
|
60
|
+
env: { ...process.env, ...TRANSITION_ENV },
|
|
61
|
+
timeout: timeoutMs,
|
|
62
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
63
|
+
});
|
|
64
|
+
journal({ action, worktree: worktreeRoot, args, ok: true });
|
|
65
|
+
return stdout;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const stderr = String(error.stderr ?? error.message).trim();
|
|
68
|
+
journal({ action, worktree: worktreeRoot, args, ok: false, error: stderr.slice(0, 500) });
|
|
69
|
+
throw new TransitionError(action, worktreeRoot, stderr);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bring one branch's objects and remote-tracking ref in from a named remote. */
|
|
74
|
+
function fetchBranch(worktreeRoot, remote, branch) {
|
|
75
|
+
return run('fetch-branch', worktreeRoot, [
|
|
76
|
+
'-C', worktreeRoot, 'fetch', '--no-tags', remote,
|
|
77
|
+
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fast-forward the current branch to an exact commit. Refuses anything but a ff. */
|
|
82
|
+
function fastForwardTo(worktreeRoot, oid) {
|
|
83
|
+
return run('fast-forward', worktreeRoot, ['-C', worktreeRoot, 'merge', '--ff-only', oid], {
|
|
84
|
+
timeoutMs: 60_000,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply a captured worktree patch — or reverse one — from a file on disk.
|
|
90
|
+
* The patch file lives outside the repo (in the state dir), so what was
|
|
91
|
+
* applied stays inspectable after the fact.
|
|
92
|
+
*/
|
|
93
|
+
function applyPatch(worktreeRoot, patchFile, { reverse = false } = {}) {
|
|
94
|
+
return run(reverse ? 'reverse-patch' : 'apply-patch', worktreeRoot, [
|
|
95
|
+
'-C', worktreeRoot, 'apply', ...(reverse ? ['-R'] : []), '--whitespace=nowarn', patchFile,
|
|
96
|
+
], { timeoutMs: 60_000 });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Land a merged write set in a working tree — the coexistence engine's
|
|
101
|
+
* only mutation path. Each entry is { path, content, mode } (content a
|
|
102
|
+
* Buffer, mode '100644' | '100755' | '120000') or { path, content: null }
|
|
103
|
+
* for a deletion. Paths are confined to the worktree; the whole set lands
|
|
104
|
+
* as one journal line.
|
|
105
|
+
*/
|
|
106
|
+
function applyMergedFiles(worktreeRoot, writes, label) {
|
|
107
|
+
const root = path.resolve(worktreeRoot);
|
|
108
|
+
const written = [];
|
|
109
|
+
const removed = [];
|
|
110
|
+
try {
|
|
111
|
+
for (const write of writes) {
|
|
112
|
+
const abs = path.resolve(root, write.path);
|
|
113
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
114
|
+
throw new Error(`unsafe path: ${write.path}`);
|
|
115
|
+
}
|
|
116
|
+
if (write.content === null) {
|
|
117
|
+
fs.rmSync(abs, { force: true });
|
|
118
|
+
removed.push(write.path);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
122
|
+
if (write.mode === '120000') {
|
|
123
|
+
fs.rmSync(abs, { force: true });
|
|
124
|
+
fs.symlinkSync(write.content.toString('utf8'), abs);
|
|
125
|
+
} else {
|
|
126
|
+
fs.writeFileSync(abs, write.content);
|
|
127
|
+
fs.chmodSync(abs, write.mode === '100755' ? 0o755 : 0o644);
|
|
128
|
+
}
|
|
129
|
+
written.push(write.path);
|
|
130
|
+
}
|
|
131
|
+
journal({ action: 'merge-files', worktree: root, label, written, removed, ok: true });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
journal({
|
|
134
|
+
action: 'merge-files', worktree: root, label, written, removed, ok: false,
|
|
135
|
+
error: String(error.message).slice(0, 500),
|
|
136
|
+
});
|
|
137
|
+
throw new TransitionError('merge-files', root, String(error.message));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { TransitionError, applyMergedFiles, applyPatch, fastForwardTo, fetchBranch };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ROLES = Object.freeze(['owner', 'editor', 'viewer', 'no_access']);
|
|
4
|
+
|
|
5
|
+
const CAPABILITIES = Object.freeze({
|
|
6
|
+
owner: Object.freeze([
|
|
7
|
+
'view',
|
|
8
|
+
'edit',
|
|
9
|
+
'create',
|
|
10
|
+
'rename',
|
|
11
|
+
'move',
|
|
12
|
+
'trash',
|
|
13
|
+
'restore',
|
|
14
|
+
'manage_access',
|
|
15
|
+
'purge',
|
|
16
|
+
]),
|
|
17
|
+
editor: Object.freeze(['view', 'edit', 'create', 'rename', 'move', 'trash', 'restore']),
|
|
18
|
+
viewer: Object.freeze(['view', 'live_follow', 'download']),
|
|
19
|
+
no_access: Object.freeze([]),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function isRole(value) {
|
|
23
|
+
return ROLES.includes(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function capabilitiesForRole(role) {
|
|
27
|
+
return isRole(role) ? CAPABILITIES[role] : Object.freeze([]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function can(role, capability) {
|
|
31
|
+
return capabilitiesForRole(role).includes(capability);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the closest explicit permission. `parentByNode` and `roleByNode`
|
|
36
|
+
* may be Maps or plain objects. Only explicit values are stored; absence means
|
|
37
|
+
* continue to the parent. A cycle or broken parent chain fails closed.
|
|
38
|
+
*/
|
|
39
|
+
function resolveEffectiveRole(nodeId, parentByNode, roleByNode) {
|
|
40
|
+
const parentOf = (id) => parentByNode instanceof Map ? parentByNode.get(id) : parentByNode?.[id];
|
|
41
|
+
const explicitRole = (id) => roleByNode instanceof Map ? roleByNode.get(id) : roleByNode?.[id];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
let current = nodeId;
|
|
44
|
+
|
|
45
|
+
while (current) {
|
|
46
|
+
if (seen.has(current)) return null;
|
|
47
|
+
seen.add(current);
|
|
48
|
+
const role = explicitRole(current);
|
|
49
|
+
if (role !== undefined) return isRole(role) ? role : null;
|
|
50
|
+
current = parentOf(current) || null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
CAPABILITIES,
|
|
57
|
+
ROLES,
|
|
58
|
+
can,
|
|
59
|
+
capabilitiesForRole,
|
|
60
|
+
isRole,
|
|
61
|
+
resolveEffectiveRole,
|
|
62
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
function bytesToUuid(bytes) {
|
|
9
|
+
const hex = Buffer.from(bytes).toString('hex');
|
|
10
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mint an RFC 9562 UUIDv7. The timestamp makes offline-created nodes roughly
|
|
15
|
+
* creation ordered; the remaining 74 bits are random, so no coordinator is
|
|
16
|
+
* needed and the id never depends on a machine, path, or name.
|
|
17
|
+
*/
|
|
18
|
+
function uuidV7(now = Date.now(), randomBytes = crypto.randomBytes) {
|
|
19
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > 0xffffffffffff) {
|
|
20
|
+
throw new TypeError('UUIDv7 timestamp must be an integer between 0 and 2^48 - 1');
|
|
21
|
+
}
|
|
22
|
+
const bytes = Buffer.from(randomBytes(16));
|
|
23
|
+
if (bytes.length !== 16) throw new TypeError('UUIDv7 random source must return 16 bytes');
|
|
24
|
+
|
|
25
|
+
let timestamp = BigInt(now);
|
|
26
|
+
for (let index = 5; index >= 0; index -= 1) {
|
|
27
|
+
bytes[index] = Number(timestamp & 0xffn);
|
|
28
|
+
timestamp >>= 8n;
|
|
29
|
+
}
|
|
30
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
31
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
32
|
+
return bytesToUuid(bytes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isUuid(value) {
|
|
36
|
+
return typeof value === 'string' && UUID_RE.test(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isUuidV7(value) {
|
|
40
|
+
return typeof value === 'string' && UUID_V7_RE.test(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function uuidV7Timestamp(value) {
|
|
44
|
+
if (!isUuidV7(value)) return null;
|
|
45
|
+
return Number.parseInt(value.replaceAll('-', '').slice(0, 12), 16);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
isUuid,
|
|
50
|
+
isUuidV7,
|
|
51
|
+
uuidV7,
|
|
52
|
+
uuidV7Timestamp,
|
|
53
|
+
};
|