amalgm 0.1.165 → 0.1.166

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.165",
3
+ "version": "0.1.166",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -72,6 +72,26 @@ test('enrollment gives every node a UUID and open docs keep one resource through
72
72
  assert.equal(state.node_id, beforeNode.id);
73
73
  });
74
74
 
75
+ test('bounded automatic tree passes skip large workspaces without blocking explicit enrollment', () => {
76
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'bounded-observer');
77
+ fs.mkdirSync(root, { recursive: true });
78
+ fs.writeFileSync(path.join(root, 'one.txt'), 'one\n');
79
+ fs.writeFileSync(path.join(root, 'two.txt'), 'two\n');
80
+ const workspace = upsertWorkspace({ path: root, name: 'Bounded observer', source: 'computer' });
81
+
82
+ const boundedEnrollment = enrollWorkspace(workspace.id, { maxNodes: 1 });
83
+ assert.equal(boundedEnrollment.skipped, 'node_limit');
84
+ assert.equal(listWorkspaceNodes(workspace.id).length, 1, 'bounded scan must not persist a partial tree');
85
+
86
+ const explicitEnrollment = enrollWorkspace(workspace.id);
87
+ assert.equal(explicitEnrollment.nodes.length, 3, 'explicit enrollment remains available for intentional work');
88
+
89
+ fs.writeFileSync(path.join(root, 'three.txt'), 'three\n');
90
+ const boundedReconcile = reconcileWorkspaceTree(workspace.id, { maxNodes: 1 });
91
+ assert.equal(boundedReconcile.skipped, 'node_limit');
92
+ assert.equal(nodeForPath(path.join(root, 'three.txt')), null, 'automatic reconciliation skips the oversized tree');
93
+ });
94
+
75
95
  test('first-update enrollment assigns UUIDs to existing files and ignores nested noise', async () => {
76
96
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'existing-before-update');
77
97
  const repo = path.join(root, 'packages', 'repo');
@@ -19,6 +19,11 @@ const { normalizeTreeOperation } = require('./tree/operations');
19
19
  const { uuidV7 } = require('./tree/identity');
20
20
 
21
21
  const TREE_RESOURCE_PREFIX = 'workspace-tree:';
22
+ // Tree enrollment and disk observation run inside the MCP process. Keep those
23
+ // automatic passes bounded so one very large workspace cannot monopolize the
24
+ // event loop and make the runtime health endpoint unavailable. Explicit
25
+ // workspace operations remain uncapped.
26
+ const AUTOMATIC_TREE_NODE_LIMIT = 2_000;
22
27
 
23
28
  function invalid(message, statusCode = 400) {
24
29
  return Object.assign(new Error(message), { statusCode });
@@ -84,7 +89,29 @@ function statIdentity(stats) {
84
89
  return { deviceId: String(stats.dev), inode: String(stats.ino) };
85
90
  }
86
91
 
87
- function scanDirectory(rootPath) {
92
+ function treeNodeLimit(options = {}) {
93
+ if (options.maxNodes == null) return null;
94
+ const value = Number(options.maxNodes);
95
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
96
+ }
97
+
98
+ function treeNodeLimitError(limit) {
99
+ return Object.assign(new Error(`workspace tree exceeds automatic limit of ${limit} nodes`), {
100
+ code: 'WORKSPACE_TREE_NODE_LIMIT',
101
+ });
102
+ }
103
+
104
+ function skippedTreeResult(workspaceId, limit, nodeCount = null) {
105
+ return {
106
+ workspaceId,
107
+ skipped: 'node_limit',
108
+ maxNodes: limit,
109
+ ...(nodeCount == null ? {} : { nodeCount }),
110
+ };
111
+ }
112
+
113
+ function scanDirectory(rootPath, options = {}) {
114
+ const maxNodes = treeNodeLimit(options);
88
115
  const entries = [];
89
116
  const visit = (directory, parentRelativePath) => {
90
117
  const children = fs.readdirSync(directory, { withFileTypes: true })
@@ -95,6 +122,7 @@ function scanDirectory(rootPath) {
95
122
  const absolutePath = path.join(directory, child.name);
96
123
  const stats = fs.lstatSync(absolutePath);
97
124
  const kind = stats.isSymbolicLink() ? 'symlink' : stats.isDirectory() ? 'folder' : 'file';
125
+ if (maxNodes != null && entries.length >= maxNodes) throw treeNodeLimitError(maxNodes);
98
126
  entries.push({
99
127
  relativePath,
100
128
  parentRelativePath,
@@ -124,7 +152,23 @@ function enrollWorkspace(identifier, options = {}) {
124
152
  const rootStats = fs.statSync(rootPath);
125
153
  if (!rootStats.isDirectory()) throw invalid('workspace path is not a folder', 409);
126
154
 
127
- const scanned = scanDirectory(rootPath);
155
+ const maxNodes = treeNodeLimit(options);
156
+ const existingNodeCount = database.prepare(
157
+ 'SELECT count(*) AS count FROM workspace_nodes WHERE workspace_id = ?',
158
+ ).get(workspace.id).count;
159
+ if (maxNodes != null && existingNodeCount > maxNodes) {
160
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
161
+ }
162
+
163
+ let scanned;
164
+ try {
165
+ scanned = scanDirectory(rootPath, { maxNodes });
166
+ } catch (error) {
167
+ if (error?.code === 'WORKSPACE_TREE_NODE_LIMIT') {
168
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
169
+ }
170
+ throw error;
171
+ }
128
172
  const existing = database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id);
129
173
  const byPath = new Map(existing.map((row) => [row.relative_path, row]));
130
174
  const byIdentity = new Map();
@@ -487,8 +531,8 @@ function enrollRegisteredWorkspaces(options = {}) {
487
531
  const results = [];
488
532
  for (const workspace of workspaces) {
489
533
  try {
490
- const result = enrollWorkspace(workspace.id, { database });
491
- ensureWorkspaceTreePromotion(workspace.id, { database });
534
+ const result = enrollWorkspace(workspace.id, { database, maxNodes: options.maxNodes });
535
+ if (!result.skipped) ensureWorkspaceTreePromotion(workspace.id, { database });
492
536
  results.push(result);
493
537
  } catch (error) {
494
538
  console.warn(`[WorkspaceTree] enrollment failed for ${workspace.id}:`, error?.message || error);
@@ -506,6 +550,13 @@ function reconcileWorkspaceTree(identifier, options = {}) {
506
550
  const database = options.database || openLocalDb();
507
551
  const workspace = workspaceRow(identifier, database);
508
552
  if (!workspace || !fs.existsSync(workspace.path)) return { operations: [] };
553
+ const maxNodes = treeNodeLimit(options);
554
+ const existingNodeCount = database.prepare(
555
+ 'SELECT count(*) AS count FROM workspace_nodes WHERE workspace_id = ?',
556
+ ).get(workspace.id).count;
557
+ if (maxNodes != null && existingNodeCount > maxNodes) {
558
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
559
+ }
509
560
  const operations = [];
510
561
  const held = [];
511
562
  const heldNodeIds = new Set();
@@ -524,7 +575,15 @@ function reconcileWorkspaceTree(identifier, options = {}) {
524
575
  }
525
576
  };
526
577
 
527
- const scanned = scanDirectory(workspace.path);
578
+ let scanned;
579
+ try {
580
+ scanned = scanDirectory(workspace.path, { maxNodes });
581
+ } catch (error) {
582
+ if (error?.code === 'WORKSPACE_TREE_NODE_LIMIT') {
583
+ return skippedTreeResult(workspace.id, maxNodes, existingNodeCount);
584
+ }
585
+ throw error;
586
+ }
528
587
  let rows = listWorkspaceNodes(workspace.id, {
529
588
  database,
530
589
  includeTrashed: true,
@@ -605,7 +664,7 @@ function reconcileRegisteredWorkspaceTrees(options = {}) {
605
664
  const results = [];
606
665
  for (const workspace of database.prepare('SELECT id FROM workspaces ORDER BY created_at').all()) {
607
666
  try {
608
- results.push(reconcileWorkspaceTree(workspace.id, { database }));
667
+ results.push(reconcileWorkspaceTree(workspace.id, { database, maxNodes: options.maxNodes }));
609
668
  } catch (error) {
610
669
  console.warn(`[WorkspaceTree] observation failed for ${workspace.id}:`, error?.message || error);
611
670
  }
@@ -617,9 +676,15 @@ let enrollmentStarted = false;
617
676
  function startTreeEnrollmentService() {
618
677
  if (enrollmentStarted) return;
619
678
  enrollmentStarted = true;
620
- const timer = setTimeout(() => enrollRegisteredWorkspaces(), 0);
679
+ const timer = setTimeout(
680
+ () => enrollRegisteredWorkspaces({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
681
+ 0,
682
+ );
621
683
  if (typeof timer.unref === 'function') timer.unref();
622
- const observationTimer = setInterval(() => reconcileRegisteredWorkspaceTrees(), 2_000);
684
+ const observationTimer = setInterval(
685
+ () => reconcileRegisteredWorkspaceTrees({ maxNodes: AUTOMATIC_TREE_NODE_LIMIT }),
686
+ 2_000,
687
+ );
623
688
  if (typeof observationTimer.unref === 'function') observationTimer.unref();
624
689
  const promotionTimer = setInterval(() => {
625
690
  try {