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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 {
|