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
@@ -369,9 +369,13 @@ function createSharedRealtimeTunnel({
369
369
  snapshotBase64: frame.snapshot_b64,
370
370
  contract: frame.contract,
371
371
  schemaVersion: frame.schema_version,
372
+ role: frame.role,
373
+ permissions: frame.permissions,
374
+ contentResources: frame.content_resources,
372
375
  }).then(() => {
373
376
  attachmentInflight.delete(frame.request_id);
374
377
  void joinReplicas();
378
+ scheduleAttachmentDrain();
375
379
  }).catch((error) => {
376
380
  void localStateJson('POST', '/state/shared/attachments/fail', {
377
381
  attachmentId: identity.attachmentId,
@@ -457,7 +461,11 @@ function createSharedRealtimeTunnel({
457
461
  const joinedResource = joinInflight.get(frame.request_id);
458
462
  if (joinedResource) {
459
463
  joinInflight.delete(frame.request_id);
460
- if (frame.code === 'shared_tail_pruned' || frame.code === 'shared_tail_too_large') {
464
+ if (
465
+ frame.code === 'shared_tail_pruned'
466
+ || frame.code === 'shared_tail_too_large'
467
+ || frame.code === 'workspace_tree_snapshot_required'
468
+ ) {
461
469
  // The official tail can no longer reach this replica's cursor —
462
470
  // compaction pruned past it, or the gap outgrew incremental
463
471
  // recovery. Recover through a fresh snapshot attachment; its ack
@@ -547,6 +555,13 @@ function createSharedRealtimeTunnel({
547
555
  case 'shared.resource.attached':
548
556
  handleAttached(frame);
549
557
  return true;
558
+ case 'shared.resource.resync':
559
+ void localStateJson('POST', '/state/shared/replicas/rehydrate', {
560
+ resourceId: frame.resource_id,
561
+ }).then(() => scheduleAttachmentDrain()).catch((error) => {
562
+ warn(`shared tree resync request failed for ${frame.resource_id}: ${error?.message || error}`);
563
+ });
564
+ return true;
550
565
  case 'shared.mutation.committed':
551
566
  handleCommitted(frame);
552
567
  return true;
@@ -561,6 +576,20 @@ function createSharedRealtimeTunnel({
561
576
  headVersion: frame.head_version,
562
577
  contract: frame.contract,
563
578
  schemaVersion: frame.schema_version,
579
+ }).then(async (replica) => {
580
+ if (frame.contract === 'workspace-tree@1' && replica.authorityChanged) {
581
+ await localStateJson('POST', '/state/shared/replicas/rehydrate', {
582
+ resourceId: frame.resource_id,
583
+ });
584
+ scheduleAttachmentDrain();
585
+ return;
586
+ }
587
+ if (frame.contract !== 'workspace-tree@1' || !Array.isArray(frame.content_resources)) return;
588
+ await localStateJson('POST', '/state/shared/workspace-content/discover', {
589
+ workspaceId: frame.resource_id,
590
+ contentResources: frame.content_resources,
591
+ });
592
+ scheduleAttachmentDrain();
564
593
  }).catch(() => {});
565
594
  return true;
566
595
  case 'private.resource.ready':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.161",
3
+ "version": "0.1.162",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -704,6 +704,10 @@ async function handleWrite(body, sendJson) {
704
704
  ? Buffer.from(String(body.content), 'base64')
705
705
  : Buffer.from(String(body.content), 'utf-8');
706
706
 
707
+ const tree = require('../workspace/tree-store');
708
+ const existingNode = tree.nodeForPath(targetPath);
709
+ if (existingNode) tree.requirePathCapability(targetPath, 'edit');
710
+ else tree.commitPathCreate(targetPath, 'file', { source: 'fs:write:create' });
707
711
  await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
708
712
  await fs.promises.writeFile(targetPath, data);
709
713
  notifyProjectContextPathChanged(targetPath, 'fs:write');
@@ -720,7 +724,10 @@ async function handleDelete(body, sendJson) {
720
724
  try {
721
725
  const targetPath = await resolveSafePath(body.path);
722
726
  const stats = await fs.promises.lstat(targetPath).catch(() => null);
723
- await fs.promises.rm(targetPath, { recursive: true, force: true });
727
+ const treeResult = require('../workspace/tree-store').commitPathTrash(targetPath, {
728
+ source: 'fs:trash',
729
+ });
730
+ if (!treeResult) await fs.promises.rm(targetPath, { recursive: true, force: true });
724
731
  notifyProjectContextPathChanged(targetPath, 'fs:delete');
725
732
  await publishFilesystemChange([targetPath], 'fs:delete');
726
733
  if (stats?.isDirectory()) {
@@ -732,7 +739,12 @@ async function handleDelete(body, sendJson) {
732
739
  source: 'fs:delete',
733
740
  });
734
741
  }
735
- sendJson(200, { success: true, path: targetPath });
742
+ sendJson(200, {
743
+ success: true,
744
+ path: targetPath,
745
+ trashed: Boolean(treeResult),
746
+ nodeId: treeResult?.operation?.nodeId || null,
747
+ });
736
748
  } catch (error) {
737
749
  const message = error instanceof Error ? error.message : 'Failed to delete';
738
750
  sendJson(statusForError(error), { error: message });
@@ -742,10 +754,12 @@ async function handleDelete(body, sendJson) {
742
754
  async function handleMkdir(body, sendJson) {
743
755
  try {
744
756
  const targetPath = await resolveSafePath(body.path, { forCreate: true });
745
- await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });
757
+ const treeResult = require('../workspace/tree-store')
758
+ .commitPathCreate(targetPath, 'folder', { source: 'fs:mkdir' });
759
+ if (!treeResult) await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });
746
760
  await publishFilesystemChange([targetPath], 'fs:mkdir');
747
761
  await publishFilesResourceForPath(targetPath, 'fs:mkdir');
748
- sendJson(200, { success: true, path: targetPath });
762
+ sendJson(200, { success: true, path: targetPath, nodeId: treeResult?.operation?.nodeId || null });
749
763
  } catch (error) {
750
764
  const message = error instanceof Error ? error.message : 'Failed to create folder';
751
765
  sendJson(statusForError(error), { error: message });
@@ -757,8 +771,13 @@ async function handleRename(body, sendJson) {
757
771
  const oldPath = await resolveSafePath(body.oldPath);
758
772
  const newPath = await resolveSafePath(body.newPath, { forCreate: true });
759
773
  const stats = await fs.promises.lstat(oldPath).catch(() => null);
760
- await fs.promises.mkdir(path.dirname(newPath), { recursive: true });
761
- await fs.promises.rename(oldPath, newPath);
774
+ const treeResult = require('../workspace/tree-store').commitPathRename(oldPath, newPath, {
775
+ source: 'fs:rename',
776
+ });
777
+ if (!treeResult) {
778
+ await fs.promises.mkdir(path.dirname(newPath), { recursive: true });
779
+ await fs.promises.rename(oldPath, newPath);
780
+ }
762
781
  notifyProjectContextPathChanged(oldPath, 'fs:rename');
763
782
  notifyProjectContextPathChanged(newPath, 'fs:rename');
764
783
  await publishFilesystemChange([oldPath, newPath], 'fs:rename');
@@ -772,7 +791,7 @@ async function handleRename(body, sendJson) {
772
791
  });
773
792
  await publishFilesResourceForPath(newPath, 'fs:rename');
774
793
  }
775
- sendJson(200, { success: true, oldPath, newPath });
794
+ sendJson(200, { success: true, oldPath, newPath, nodeId: treeResult?.operation?.nodeId || null });
776
795
  } catch (error) {
777
796
  const message = error instanceof Error ? error.message : 'Failed to rename';
778
797
  sendJson(statusForError(error), { error: message });
@@ -128,7 +128,10 @@ async function boot() {
128
128
  startHealthMonitor();
129
129
  require('./state/ports').startPortsBridge();
130
130
  require('./workspace/cards').startRepoCardService();
131
+ require('./workspace/tree-store').startTreeEnrollmentService();
131
132
  require('./workspace/wall').startWallService();
133
+ require('./workspace/checkpoint').startCheckpointService();
134
+ require('./workspace/reconcile').startWorkspaceReconciler();
132
135
  }
133
136
 
134
137
  boot().catch((err) => {
@@ -103,6 +103,10 @@ async function handleStateRoutes(ctx) {
103
103
  await sharedRest.handleReplicaRehydrate(await ctx.readJsonBody(), ctx.sendJson);
104
104
  return true;
105
105
  }
106
+ if (ctx.pathname === '/state/shared/workspace-content/discover') {
107
+ await sharedRest.handleWorkspaceContentDiscover(await ctx.readJsonBody(), ctx.sendJson);
108
+ return true;
109
+ }
106
110
  if (ctx.pathname === '/state/shared/promote') {
107
111
  await sharedRest.handlePromotionStart(await ctx.readJsonBody(), ctx.sendJson);
108
112
  return true;
@@ -67,6 +67,38 @@ async function handleWorkspaceRoutes(ctx) {
67
67
  workspaceRest.handleWorkspaceMachines(ctx.sendJson);
68
68
  return true;
69
69
  }
70
+ if (ctx.pathname === '/workspace/tree' && ctx.method === 'GET') {
71
+ workspaceRest.handleTreeList(ctx.getQuery(), ctx.sendJson);
72
+ return true;
73
+ }
74
+ if (ctx.pathname === '/workspace/tree/enroll' && ctx.method === 'POST') {
75
+ workspaceRest.handleTreeEnroll(await ctx.readJsonBody(), ctx.sendJson);
76
+ return true;
77
+ }
78
+ if (ctx.pathname === '/workspace/tree/mutations' && ctx.method === 'POST') {
79
+ workspaceRest.handleTreeMutation(await ctx.readJsonBody(), ctx.sendJson);
80
+ return true;
81
+ }
82
+ if (ctx.pathname === '/workspace/cloud-attachments' && ctx.method === 'POST') {
83
+ await workspaceRest.handleCloudWorkspaceAttachment(await ctx.readJsonBody(), ctx.sendJson);
84
+ return true;
85
+ }
86
+ if (ctx.pathname === '/workspace/access' && ctx.method === 'GET') {
87
+ workspaceRest.handleAccessRead(ctx.getQuery(), ctx.sendJson);
88
+ return true;
89
+ }
90
+ if (ctx.pathname === '/workspace/access' && ctx.method === 'PUT') {
91
+ workspaceRest.handleAccessSet(await ctx.readJsonBody(), ctx.sendJson);
92
+ return true;
93
+ }
94
+ if (ctx.pathname === '/workspace/access' && ctx.method === 'DELETE') {
95
+ workspaceRest.handleAccessClear(await ctx.readJsonBody(), ctx.sendJson);
96
+ return true;
97
+ }
98
+ if (ctx.pathname === '/workspace/access/revoke-user' && ctx.method === 'POST') {
99
+ workspaceRest.handleAccessRevokeUser(await ctx.readJsonBody(), ctx.sendJson);
100
+ return true;
101
+ }
70
102
  return false;
71
103
  }
72
104
 
@@ -3,9 +3,9 @@
3
3
  const crypto = require('crypto');
4
4
 
5
5
  const { cachedStatement, openLocalDb } = require('./db');
6
+ const { isSharedResourceId } = require('./cloud-resource-id');
6
7
  const { appendStateEvent } = require('./events');
7
8
 
8
- const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
9
9
  const ATTACHMENT_ID = /^[A-Za-z0-9_-]{16,160}$/;
10
10
 
11
11
  function invalid(message, statusCode = 400) {
@@ -40,7 +40,7 @@ function enqueueAttachment(input, options = {}) {
40
40
  const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
41
41
  const localPath = requiredText(input?.localPath, 'localPath');
42
42
  if (!ATTACHMENT_ID.test(attachmentId)) throw invalid('attachmentId has an invalid format');
43
- if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
43
+ if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
44
44
  const database = options.database || openLocalDb();
45
45
  const existing = cachedStatement(
46
46
  database,
@@ -127,7 +127,9 @@ function acknowledgeAttachment(attachmentIdInput, payload, options = {}) {
127
127
  throw invalid('attachment response resource mismatch', 409);
128
128
  }
129
129
 
130
- const installed = require('./docs').installCloudDocSnapshot(attachment.localPath, payload);
130
+ const installed = payload?.contract === 'workspace-tree@1'
131
+ ? require('../workspace/tree-store').installCloudTreeSnapshot(attachment.localPath, payload, { database })
132
+ : require('./docs').installCloudDocSnapshot(attachment.localPath, payload);
131
133
  const now = new Date().toISOString();
132
134
  cachedStatement(database, `
133
135
  UPDATE shared_resource_attachments
@@ -180,7 +182,7 @@ function failAttachment(attachmentIdInput, failure, options = {}) {
180
182
  */
181
183
  function requestRehydration(input, options = {}) {
182
184
  const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
183
- if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
185
+ if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
184
186
  const database = options.database || openLocalDb();
185
187
  const replica = require('./replicas').getReplicaByResourceId(resourceId, { database });
186
188
  if (!replica) throw invalid(`shared resource ${resourceId} has no local replica`, 404);
@@ -248,7 +250,7 @@ function getAttachmentByResource(resourceId, options = {}) {
248
250
  */
249
251
  function retryAttachment(resourceIdInput, options = {}) {
250
252
  const resourceId = requiredText(resourceIdInput, 'resourceId', 132);
251
- if (!SHARED_RESOURCE_ID.test(resourceId)) throw invalid('resourceId has an invalid format');
253
+ if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
252
254
  const database = options.database || openLocalDb();
253
255
  const now = new Date().toISOString();
254
256
  const result = cachedStatement(database, `
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const { isUuid } = require('../workspace/tree/identity');
4
+
5
+ const LEGACY_SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
6
+ const PRIVATE_RESOURCE_ID = /^pvr_[A-Za-z0-9_-]{16,128}$/;
7
+
8
+ function isSharedResourceId(value) {
9
+ return typeof value === 'string' && (LEGACY_SHARED_RESOURCE_ID.test(value) || isUuid(value));
10
+ }
11
+
12
+ function isCloudResourceId(value) {
13
+ return isSharedResourceId(value) || (typeof value === 'string' && PRIVATE_RESOURCE_ID.test(value));
14
+ }
15
+
16
+ module.exports = { isCloudResourceId, isSharedResourceId };
@@ -532,6 +532,70 @@ function migrate(database = openLocalDb()) {
532
532
  CREATE INDEX IF NOT EXISTS workspaces_location_type_idx
533
533
  ON workspaces(location_type);
534
534
 
535
+ -- Permanent workspace/tree identity. A workspace's root node uses the
536
+ -- workspace UUID itself, so the root never acquires a second durable id.
537
+ CREATE TABLE IF NOT EXISTS workspace_id_aliases (
538
+ alias_id TEXT PRIMARY KEY,
539
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
540
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
541
+ );
542
+
543
+ CREATE INDEX IF NOT EXISTS workspace_id_aliases_workspace_idx
544
+ ON workspace_id_aliases(workspace_id);
545
+
546
+ CREATE TABLE IF NOT EXISTS workspace_nodes (
547
+ id TEXT PRIMARY KEY,
548
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
549
+ parent_id TEXT REFERENCES workspace_nodes(id) ON UPDATE CASCADE,
550
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'file', 'symlink')),
551
+ name TEXT NOT NULL,
552
+ relative_path TEXT NOT NULL,
553
+ symlink_target TEXT,
554
+ device_id TEXT,
555
+ inode TEXT,
556
+ trashed_at TEXT,
557
+ purged_at TEXT,
558
+ missing_at TEXT,
559
+ created_at TEXT NOT NULL,
560
+ updated_at TEXT NOT NULL,
561
+ CHECK ((id = workspace_id AND parent_id IS NULL AND relative_path = '')
562
+ OR (id <> workspace_id AND parent_id IS NOT NULL AND relative_path <> ''))
563
+ );
564
+
565
+ DROP INDEX IF EXISTS workspace_nodes_live_path_idx;
566
+ CREATE UNIQUE INDEX workspace_nodes_live_path_idx
567
+ ON workspace_nodes(workspace_id, relative_path)
568
+ WHERE purged_at IS NULL AND missing_at IS NULL AND trashed_at IS NULL;
569
+
570
+ CREATE INDEX IF NOT EXISTS workspace_nodes_parent_idx
571
+ ON workspace_nodes(workspace_id, parent_id);
572
+
573
+ CREATE INDEX IF NOT EXISTS workspace_nodes_inode_idx
574
+ ON workspace_nodes(workspace_id, device_id, inode)
575
+ WHERE device_id IS NOT NULL AND inode IS NOT NULL AND purged_at IS NULL;
576
+
577
+ CREATE TABLE IF NOT EXISTS workspace_access_state (
578
+ workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
579
+ authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch > 0),
580
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
581
+ );
582
+
583
+ CREATE TABLE IF NOT EXISTS workspace_node_permissions (
584
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
585
+ node_id TEXT NOT NULL REFERENCES workspace_nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
586
+ user_id TEXT NOT NULL,
587
+ role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer', 'no_access')),
588
+ permission_version INTEGER NOT NULL DEFAULT 1 CHECK (permission_version > 0),
589
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
590
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
591
+ revoked_at TEXT,
592
+ PRIMARY KEY (node_id, user_id)
593
+ );
594
+
595
+ CREATE INDEX IF NOT EXISTS workspace_node_permissions_user_idx
596
+ ON workspace_node_permissions(user_id, workspace_id)
597
+ WHERE revoked_at IS NULL;
598
+
535
599
  CREATE TABLE IF NOT EXISTS repo_replicas (
536
600
  git_dir TEXT PRIMARY KEY,
537
601
  replica_id TEXT NOT NULL UNIQUE,
@@ -681,6 +745,16 @@ function ensureDocStatesSchema(database = openLocalDb()) {
681
745
  } catch {
682
746
  // Column already exists.
683
747
  }
748
+ try {
749
+ database.exec('ALTER TABLE doc_states ADD COLUMN node_id TEXT');
750
+ } catch {
751
+ // Column already exists.
752
+ }
753
+ database.exec(`
754
+ CREATE UNIQUE INDEX IF NOT EXISTS doc_states_node_id_idx
755
+ ON doc_states(node_id)
756
+ WHERE node_id IS NOT NULL;
757
+ `);
684
758
  }
685
759
 
686
760
  // App-registered resources (state/app-resources.js): the engine-side mirror
@@ -179,13 +179,23 @@ function liveEditsSinceLastDiskState(entry) {
179
179
  }
180
180
 
181
181
  function docResourceName(absolutePath) {
182
+ try {
183
+ const found = require('../workspace/tree-store').nodeForPath(absolutePath);
184
+ if (found?.node?.kind === 'file') return `${DOC_RESOURCE_PREFIX}${found.node.id}`;
185
+ } catch {
186
+ // Unenrolled files keep the legacy path-derived local resource.
187
+ }
182
188
  return `${DOC_RESOURCE_PREFIX}${fsPrivate().base64UrlEncode(absolutePath)}`;
183
189
  }
184
190
 
185
191
  function pathFromDocResource(resource) {
186
192
  if (typeof resource !== 'string' || !resource.startsWith(DOC_RESOURCE_PREFIX)) return null;
193
+ const identity = resource.slice(DOC_RESOURCE_PREFIX.length);
194
+ if (require('../workspace/tree/identity').isUuid(identity)) {
195
+ return require('../workspace/tree-store').absolutePathForNode(identity);
196
+ }
187
197
  try {
188
- return fsPrivate().base64UrlDecode(resource.slice(DOC_RESOURCE_PREFIX.length));
198
+ return fsPrivate().base64UrlDecode(identity);
189
199
  } catch {
190
200
  return null;
191
201
  }
@@ -282,6 +292,16 @@ function spliceDocText(Y, entry, nextText, source) {
282
292
  return { mutationId: newMutationId(), update: origin.patch.yjs, bytes: null };
283
293
  }
284
294
 
295
+ function canCurrentUserEditDoc(entry) {
296
+ if (!entry.nodeId) return true;
297
+ const role = require('../workspace/access-store').currentUserRole(entry.nodeId);
298
+ return require('../workspace/tree/access').can(role, 'edit');
299
+ }
300
+
301
+ function requireCurrentUserDocEdit(entry) {
302
+ if (!canCurrentUserEditDoc(entry)) throw invalid('document edit is forbidden', 403);
303
+ }
304
+
285
305
  /**
286
306
  * The one door. Every locally produced document edit — a client Yjs update,
287
307
  * a whole-text API write, a disk reconcile — becomes exactly one journal
@@ -361,14 +381,16 @@ function publishConflictOnly(entry, source) {
361
381
  // through it.
362
382
  function persistDocState(Y, entry) {
363
383
  cachedStatement(openLocalDb(), `
364
- INSERT INTO doc_states (path, state, updated_at, last_disk_hash)
365
- VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
384
+ INSERT INTO doc_states (path, node_id, state, updated_at, last_disk_hash)
385
+ VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
366
386
  ON CONFLICT(path) DO UPDATE SET
387
+ node_id = excluded.node_id,
367
388
  state = excluded.state,
368
389
  updated_at = excluded.updated_at,
369
390
  last_disk_hash = excluded.last_disk_hash
370
391
  `).run(
371
392
  entry.path,
393
+ entry.nodeId,
372
394
  Buffer.from(Y.encodeStateAsUpdate(entry.doc)),
373
395
  entry.lastDiskHash,
374
396
  );
@@ -475,6 +497,11 @@ function reconcileFromDisk(Y, entry) {
475
497
  // of disk (write-back still debounced) would "reconcile" the stale disk
476
498
  // text back over live edits. (This is also the THEIRS === BASE no-op.)
477
499
  if (onDisk === entry.lastDiskText) return;
500
+ if (!canCurrentUserEditDoc(entry)) {
501
+ entry.heldDiskChange = { reason: 'forbidden', at: new Date().toISOString() };
502
+ return;
503
+ }
504
+ entry.heldDiskChange = null;
478
505
  const ytext = entry.doc.getText(TEXT_KEY);
479
506
  const ours = ytext.toString();
480
507
  const base = entry.lastDiskText;
@@ -548,6 +575,18 @@ function reconcileFromDisk(Y, entry) {
548
575
  function seedFromDisk(Y, entry, persisted) {
549
576
  const knownHash = persisted?.last_disk_hash;
550
577
  if (!knownHash) {
578
+ if (!canCurrentUserEditDoc(entry)) {
579
+ try {
580
+ const onDisk = fs.readFileSync(entry.path, 'utf8');
581
+ const origin = { amalgmMutation: true, source: 'doc:read-only-seed', patch: null };
582
+ spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), onDisk, origin);
583
+ setLastDiskText(entry, onDisk, 'open');
584
+ persistDocState(Y, entry);
585
+ } catch {
586
+ // Unreadable/deleted: same posture as reconcile — stop following disk.
587
+ }
588
+ return;
589
+ }
551
590
  reconcileFromDisk(Y, entry);
552
591
  return;
553
592
  }
@@ -720,9 +759,17 @@ function loadDocEntry(pathInput) {
720
759
  ensureDocStatesSchema(database);
721
760
 
722
761
  const doc = new Y.Doc();
762
+ let nodeId = null;
763
+ try {
764
+ const found = require('../workspace/tree-store').nodeForPath(resolved);
765
+ nodeId = found?.node?.kind === 'file' ? found.node.id : null;
766
+ } catch {
767
+ // The document remains available even when tree enrollment is unavailable.
768
+ }
723
769
  const entry = {
724
770
  path: resolved,
725
771
  resource: docResourceName(resolved),
772
+ nodeId,
726
773
  doc,
727
774
  reconcileTimer: null,
728
775
  diskWriteTimer: null,
@@ -745,7 +792,14 @@ function loadDocEntry(pathInput) {
745
792
  pathAliases: new Set([pathInput]),
746
793
  };
747
794
 
748
- let persisted = cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
795
+ let persisted = nodeId
796
+ ? cachedStatement(database, `
797
+ SELECT state, last_disk_hash FROM doc_states
798
+ WHERE node_id = ? OR path = ?
799
+ ORDER BY CASE WHEN node_id = ? THEN 0 ELSE 1 END
800
+ LIMIT 1
801
+ `).get(nodeId, resolved, nodeId) || null
802
+ : cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
749
803
  if (persisted?.state) {
750
804
  try {
751
805
  Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
@@ -772,6 +826,10 @@ function loadDocEntry(pathInput) {
772
826
  // that merely lags our persisted state (the process died inside the disk
773
827
  // write debounce) gets the doc written back out — never spliced over it.
774
828
  seedFromDisk(Y, entry, persisted);
829
+ if (nodeId) {
830
+ cachedStatement(database, 'UPDATE doc_states SET node_id = ?, path = ? WHERE node_id = ? OR path = ?')
831
+ .run(nodeId, resolved, nodeId, resolved);
832
+ }
775
833
 
776
834
  // A process can die after the durable journal insert but before Y.Doc/file
777
835
  // materialization or event projection. Replay those exact operation IDs
@@ -797,6 +855,7 @@ function openDoc(pathInput) {
797
855
  const entry = loadDocEntry(pathInput);
798
856
  return {
799
857
  resource: entry.resource,
858
+ nodeId: entry.nodeId,
800
859
  path: entry.path,
801
860
  state: Buffer.from(Y.encodeStateAsUpdate(entry.doc)).toString('base64'),
802
861
  // The versioned-write handshake: hash of the exact current text; a writer
@@ -865,6 +924,7 @@ function writeDocText(pathInput, textInput, options = {}) {
865
924
 
866
925
  const entry = loadDocEntry(pathInput);
867
926
  entry.lastAccess = Date.now();
927
+ requireCurrentUserDocEdit(entry);
868
928
  // Every respond() below acknowledges saved state, so it must stand on a
869
929
  // settled journal — most pointedly the noop return: a retry of a write
870
930
  // whose disk flush failed finds the text already in memory, and without
@@ -972,6 +1032,7 @@ function applyDocMutations(pathInput, mutationsInput) {
972
1032
  if (mutations.length === 0) return { applied: 0, seq: currentSeq(), mutationIds: [] };
973
1033
  const entry = loadDocEntry(pathInput);
974
1034
  entry.lastAccess = Date.now();
1035
+ requireCurrentUserDocEdit(entry);
975
1036
  const results = mutations.map((mutation) => commitDocMutation(Y, entry, mutation, 'doc:update'));
976
1037
 
977
1038
  return {
@@ -1125,6 +1186,60 @@ function getDocText(pathInput) {
1125
1186
  return entry.doc.getText(TEXT_KEY).toString();
1126
1187
  }
1127
1188
 
1189
+ /**
1190
+ * Repoint persisted and open document materializations after tree identity
1191
+ * moved. The resource remains doc:<node UUID>; only the local path changes.
1192
+ */
1193
+ function rebindNodePaths(changes) {
1194
+ const database = openLocalDb();
1195
+ ensureDocStatesSchema(database);
1196
+ for (const change of changes || []) {
1197
+ if (!change?.nodeId || !change?.newPath || change.oldPath === change.newPath) continue;
1198
+ database.transaction(() => {
1199
+ database.prepare('UPDATE doc_states SET path = ? WHERE node_id = ? OR path = ?')
1200
+ .run(change.newPath, change.nodeId, change.oldPath);
1201
+ database.prepare('UPDATE shared_resource_replicas SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
1202
+ .run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
1203
+ database.prepare('UPDATE shared_resource_promotions SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
1204
+ .run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
1205
+ database.prepare('UPDATE shared_resource_attachments SET local_path = ?, updated_at = ? WHERE local_path = ?')
1206
+ .run(change.newPath, new Date().toISOString(), change.oldPath);
1207
+ })();
1208
+
1209
+ const entry = Array.from(openDocs.values()).find((candidate) => (
1210
+ candidate.nodeId === change.nodeId || candidate.path === change.oldPath
1211
+ ));
1212
+ if (!entry) continue;
1213
+ unwatchDocFile(entry);
1214
+ openDocs.delete(entry.path);
1215
+ entry.path = change.newPath;
1216
+ entry.nodeId = change.nodeId;
1217
+ entry.resource = `${DOC_RESOURCE_PREFIX}${change.nodeId}`;
1218
+ entry.pathAliases.add(change.newPath);
1219
+ resolvedPathCache.set(change.newPath, change.newPath);
1220
+ openDocs.set(change.newPath, entry);
1221
+ watchDocFile(loadYjs(), entry);
1222
+ }
1223
+ }
1224
+
1225
+ function closeDocByNode(nodeId) {
1226
+ const entry = Array.from(openDocs.values()).find((candidate) => candidate.nodeId === nodeId);
1227
+ if (!entry) return false;
1228
+ closeDocEntry(loadYjs(), entry);
1229
+ return true;
1230
+ }
1231
+
1232
+ function closeDocsByNodeIds(nodeIds) {
1233
+ const ids = nodeIds instanceof Set ? nodeIds : new Set(nodeIds || []);
1234
+ let closed = 0;
1235
+ for (const entry of Array.from(openDocs.values())) {
1236
+ if (!entry.nodeId || !ids.has(entry.nodeId)) continue;
1237
+ closeDocEntry(loadYjs(), entry);
1238
+ closed += 1;
1239
+ }
1240
+ return closed;
1241
+ }
1242
+
1128
1243
  /** Test/shutdown helper: flush and close every open document. */
1129
1244
  function closeAllDocs() {
1130
1245
  let Y;
@@ -1141,6 +1256,8 @@ module.exports = {
1141
1256
  applyCloudDocMutation,
1142
1257
  applyDocMutations,
1143
1258
  applyDocUpdates,
1259
+ closeDocByNode,
1260
+ closeDocsByNodeIds,
1144
1261
  closeAllDocs,
1145
1262
  docResourceName,
1146
1263
  getDocText,
@@ -1148,6 +1265,7 @@ module.exports = {
1148
1265
  openDoc,
1149
1266
  pathFromDocResource,
1150
1267
  readDocResource,
1268
+ rebindNodePaths,
1151
1269
  writeDocText,
1152
1270
  // Test hooks only — not part of the module contract.
1153
1271
  _private: { openDocs },
@@ -110,9 +110,71 @@ function removeInheritedHarnessTools(database) {
110
110
  }
111
111
  }
112
112
 
113
+ function migrateWorkspaceIds(database) {
114
+ const { isUuidV7, uuidV7 } = require('../workspace/tree/identity');
115
+ const rows = database.prepare('SELECT * FROM workspaces').all();
116
+
117
+ database.transaction(() => {
118
+ for (const row of rows) {
119
+ if (isUuidV7(row.id)) continue;
120
+ let workspaceId = uuidV7();
121
+ while (database.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId)) {
122
+ workspaceId = uuidV7();
123
+ }
124
+
125
+ let workspaceJson = {};
126
+ try {
127
+ workspaceJson = JSON.parse(row.workspace_json || '{}');
128
+ } catch {
129
+ // Typed columns remain authoritative when legacy JSON is malformed.
130
+ }
131
+ workspaceJson.id = workspaceId;
132
+
133
+ database.prepare(`
134
+ UPDATE workspaces
135
+ SET id = ?, workspace_json = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
136
+ WHERE id = ?
137
+ `).run(workspaceId, JSON.stringify(workspaceJson), row.id);
138
+ database.prepare(`
139
+ INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
140
+ VALUES (?, ?)
141
+ `).run(row.id, workspaceId);
142
+ database.prepare('UPDATE project_context SET project_id = ? WHERE project_id = ?')
143
+ .run(workspaceId, row.id);
144
+ }
145
+
146
+ database.prepare(`
147
+ INSERT OR IGNORE INTO workspace_nodes (
148
+ id, workspace_id, parent_id, kind, name, relative_path, created_at, updated_at
149
+ )
150
+ SELECT id, id, NULL, 'folder', name, '', created_at, updated_at
151
+ FROM workspaces
152
+ `).run();
153
+ database.prepare(`
154
+ INSERT OR IGNORE INTO workspace_access_state(workspace_id, authority_epoch)
155
+ SELECT id, 1 FROM workspaces
156
+ `).run();
157
+ })();
158
+ }
159
+
160
+ function backfillLocalWorkspaceOwners(database) {
161
+ const ownerUserId = require('../workspace/identity').userId();
162
+ if (!ownerUserId) return;
163
+ database.prepare(`
164
+ INSERT OR IGNORE INTO workspace_node_permissions (
165
+ workspace_id, node_id, user_id, role
166
+ )
167
+ SELECT id, id, ?, 'owner'
168
+ FROM workspaces
169
+ WHERE source <> 'cloud'
170
+ `).run(ownerUserId);
171
+ }
172
+
113
173
  function runLegacyMigrations(database) {
114
174
  migrateLegacyToolboxTables(database);
115
175
  removeInheritedHarnessTools(database);
176
+ migrateWorkspaceIds(database);
177
+ backfillLocalWorkspaceOwners(database);
116
178
  }
117
179
 
118
180
  module.exports = { runLegacyMigrations };