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.
Files changed (42) hide show
  1. package/lib/shared-realtime-tunnel.js +30 -1
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
  4. package/runtime/scripts/amalgm-mcp/index.js +3 -0
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
  7. package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
  8. package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
  9. package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
  10. package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
  11. package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
  12. package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
  13. package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
  14. package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
  15. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
  16. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
  20. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
  21. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
  26. package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
  27. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
  28. package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
  29. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
  31. package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
  32. package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
  33. package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
  35. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
  36. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
  37. package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
  38. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
  39. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
  40. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
  41. package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
  42. 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
- sendJson(200, { success: true, path: workspace.path, workspace, workspaceRoot, alreadyExists: true });
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, { success: true, path: workspace.path, workspace, workspaceRoot, alreadyExists: false });
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 id = cleanString(input.id) || workspaceIdForUri(uri);
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 existing = db
169
- .prepare('SELECT * FROM workspaces WHERE id = ? OR uri = ? OR path = ? LIMIT 1')
170
- .get(workspace.id, workspace.uri, workspace.path);
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.prepare('SELECT * FROM workspaces WHERE id = ? OR path = ? LIMIT 1').get(identifier, identifier);
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.prepare('SELECT * FROM workspaces WHERE id = ? OR path = ? LIMIT 1').get(identifier, identifier);
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
+ };