mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 04:25:11 +02:00
Distinguish declared goals, open sessions and overlap risk in coordination inventory (#3028)
* feat: add read-only coordination inventory and overlap evaluation * test: make coordination process fixtures platform explicit * test: report bounded Stop wrapper failure diagnostics * test: clean up failed memory MCP sessions deterministically * fix: update js-yaml to patched 4.3.2 * feat(coordination): distinguish declared goals from open sessions
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildInventory, normalizeManifest } = require('../../scripts/lib/coordination-inventory');
|
||||
const now = '2026-09-09T01:00:00.000Z';
|
||||
const fixture = () => ({ version: 1,
|
||||
repositories: [{ id: 'repo', sources: {} }],
|
||||
tasks: [{ id: 'worker', repoId: 'repo', paths: ['src/shared.js'], status: 'running', pid: 42 },
|
||||
{ id: 'peer', repoId: 'repo', paths: ['src/shared.js'] }], leases: [] });
|
||||
const inventory = value => buildInventory(value, { now });
|
||||
|
||||
test('goal collections distinguish missing observations from explicit empty declarations', () => {
|
||||
const missing = inventory(fixture());
|
||||
const empty = inventory({ ...fixture(), goals: [], sessions: [] });
|
||||
assert.equal(missing.coverage.goals, 'missing');
|
||||
assert.equal(missing.coverage.sessions, 'missing');
|
||||
assert.equal(empty.coverage.goals, 'declared-only');
|
||||
assert.equal(empty.coverage.sessions, 'declared-only');
|
||||
assert.deepEqual(missing.goals, []);
|
||||
assert.deepEqual(missing.sessions, []);
|
||||
assert.deepEqual(missing.activity, empty.activity);
|
||||
assert.equal(missing.activity.freshActiveNativeGoalDeclarations, 0);
|
||||
});
|
||||
|
||||
test('goal activity is never inferred from an open session, running task, heartbeat or observed PID', () => {
|
||||
const input = fixture(); input.tasks[0].heartbeatAt = now;
|
||||
input.sessions = [{ id: 'terminal', taskId: 'worker', status: 'open', updatedAt: now }];
|
||||
const report = buildInventory(input, { now, resources: {
|
||||
memory: null, processStatus: 'ok', processes: [{ pid: 42, ppid: 1, rssBytes: 1024 }] } });
|
||||
assert.equal(report.tasks[0].process.state, 'observed');
|
||||
assert.equal(report.tasks[0].heartbeat.state, 'fresh');
|
||||
assert.equal(report.activity.declaredSessionsByStatus.open, 1);
|
||||
assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 1);
|
||||
assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 0, complete: 0, blocked: 0, unknown: 0 });
|
||||
assert.equal(report.coverage.goals, 'missing');
|
||||
});
|
||||
|
||||
test('goal and session declarations remain independent and count a shared goal once', () => {
|
||||
const input = { ...fixture(), goals: [
|
||||
{ id: 'active', taskId: 'worker', kind: 'native', status: 'active', updatedAt: now },
|
||||
{ id: 'done', kind: 'native', status: 'complete', updatedAt: now },
|
||||
{ id: 'unverified', status: 'active', updatedAt: now },
|
||||
{ id: 'blocked', kind: 'native', status: 'blocked' }, { id: 'unknown' }
|
||||
], sessions: [
|
||||
{ id: 'closed', goalId: 'active', status: 'closed' },
|
||||
{ id: 'other', goalId: 'active', taskId: 'peer', status: 'open' },
|
||||
{ id: 'open-done', goalId: 'done', status: 'open' }, { id: 'unknown-session' }
|
||||
] };
|
||||
const before = JSON.stringify(input); const report = inventory(input);
|
||||
assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 2, complete: 1, blocked: 1, unknown: 1 });
|
||||
assert.deepEqual(report.activity.declaredNativeGoalsByStatus, { active: 1, complete: 1, blocked: 1, unknown: 0 });
|
||||
assert.deepEqual(report.activity.declaredSessionsByStatus, { open: 2, closed: 1, unknown: 1 });
|
||||
assert.equal(report.activity.freshActiveNativeGoalDeclarations, 1);
|
||||
assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 0);
|
||||
assert.equal(report.goals[2].kind, 'unknown');
|
||||
assert.equal(report.goals[4].status, 'unknown');
|
||||
assert.equal(report.sessions[3].status, 'unknown');
|
||||
assert.equal(report.goals[0].authority, 'declared-only');
|
||||
assert.equal(report.sessions[0].authority, 'declared-only');
|
||||
assert.equal(JSON.stringify(input), before);
|
||||
assert.deepEqual(inventory(input), report);
|
||||
});
|
||||
|
||||
test('goal freshness exposes missing stale future and boundary observations without rewriting status', () => {
|
||||
const times = [null, '2026-09-09T00:54:59.999Z', '2026-09-09T01:00:00.001Z',
|
||||
'2026-09-09T00:55:00.000Z', now];
|
||||
const report = inventory({ ...fixture(), goals: times.map((updatedAt, i) =>
|
||||
({ id: `g${i}`, kind: 'native', status: 'active', updatedAt })) });
|
||||
assert.deepEqual(report.goals.map(g => g.freshness.state), ['unknown', 'stale', 'clock-skew', 'fresh', 'fresh']);
|
||||
assert.equal(report.activity.declaredNativeGoalsByStatus.active, 5);
|
||||
assert.equal(report.activity.freshActiveNativeGoalDeclarations, 2);
|
||||
assert.ok(report.goals.every(g => g.status === 'active'));
|
||||
});
|
||||
|
||||
test('goal declarations do not change existing task resource lease or overlap outputs', () => {
|
||||
const base = fixture();
|
||||
base.leases = [{ resource: 'browser', owner: 'worker', expiresAt: now }];
|
||||
const legacy = inventory(base);
|
||||
const report = inventory({ ...base, goals: [{ id: 'completed', status: 'complete' }],
|
||||
sessions: [{ id: 'closed', status: 'closed', goalId: 'completed' }] });
|
||||
for (const key of ['tasks', 'warnings', 'resources', 'leases', 'leaseConflicts']) {
|
||||
assert.deepEqual(report[key], legacy[key]);
|
||||
}
|
||||
assert.equal(report.warnings.length, 1);
|
||||
assert.equal(report.warnings[0].action, 'review-declared-work');
|
||||
});
|
||||
|
||||
test('goal metadata drops objectives commands native blobs and other unrecognized fields', () => {
|
||||
const report = inventory({ ...fixture(), goals: [{ id: 'g', objective: 'CANARY',
|
||||
tool_result: { secret: 'CANARY' }, status: 'active', authority: 'CANARY' }],
|
||||
sessions: [{ id: 's', goalId: 'g', command: 'CANARY', environment: 'CANARY' }] });
|
||||
assert.ok(!JSON.stringify(report).includes('CANARY'));
|
||||
assert.equal(report.goals[0].authority, 'declared-only');
|
||||
});
|
||||
|
||||
test('goal input rejects malformed scalars enums dates duplicate IDs and dangling links', () => {
|
||||
for (const collection of ['goals', 'sessions']) {
|
||||
for (const value of [null, false, '', {}, 1]) {
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), [collection]: value }), /Invalid coordination input/);
|
||||
}
|
||||
for (const value of [null, false, [], 1, { id: 'bad/id' }, { id: '__proto__' },
|
||||
{ id: 'x', status: null }, { id: 'x', status: true }, { id: 'x', status: 'running' },
|
||||
{ id: 'x', updatedAt: '2026-02-30T00:00:00Z' }, { id: 'x', updatedAt: true },
|
||||
{ id: 'x', taskId: 'missing' }, { id: 'x', taskId: 1 }]) {
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [value] }), /Invalid coordination input/);
|
||||
}
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [{ id: 'same' }, { id: 'same' }] }));
|
||||
}
|
||||
for (const kind of [null, true, 1, 'verified', 'declared']) {
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), goals: [{ id: 'g', kind }] }));
|
||||
}
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 'missing' }] }));
|
||||
assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 1 }] }));
|
||||
});
|
||||
|
||||
test('goal and session cardinality and total input bounds remain enforced', () => {
|
||||
const declarations = Array.from({ length: 64 }, (_, i) => ({ id: `item${i}` }));
|
||||
const report = inventory({ ...fixture(), goals: declarations, sessions: declarations });
|
||||
assert.equal(report.goals.length, 64); assert.equal(report.sessions.length, 64);
|
||||
for (const collection of ['goals', 'sessions']) {
|
||||
assert.throws(() => inventory({ ...fixture(), [collection]: [...declarations, { id: 'extra' }] }));
|
||||
}
|
||||
assert.throws(() => inventory({ ...fixture(), goals: [{ id: 'g', ignored: 'x'.repeat(1024 * 1024) }] }));
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('../../scripts/lib/coordination-inventory');
|
||||
const now = '2026-09-08T06:30:00.000Z';
|
||||
const task = (id, paths, extra = {}) => ({ id, repoId: 'repo', paths, ...extra });
|
||||
const fixture = () => ({ version: 1, repositories: [{ id: 'repo', sources: { 'src/a.js': "require('../lib/b')", 'lib/b.js': '' } }], tasks: [task('a', ['src/a.js']), task('b', ['lib/b.js'])], leases: [] });
|
||||
const run = value => buildInventory(value, { now });
|
||||
|
||||
test('direct import warns when exact-path baseline would miss it; deterministic JSON', () => {
|
||||
const f = fixture(); const before = JSON.stringify(f); const r = run(f);
|
||||
assert.equal(r.warnings.length, 1); assert.deepEqual(r.warnings[0].reasons, ['import_dependency']);
|
||||
assert.equal(r.warnings[0].channels.dependency, 1);
|
||||
assert.equal(JSON.stringify(run(f)), JSON.stringify(r)); assert.equal(JSON.stringify(f), before);
|
||||
});
|
||||
test('normalized exact paths warn, tree-only neighbors and cross-repo pairs do not', () => {
|
||||
const f = fixture(); f.tasks[1].paths = ['./src/a.js'];
|
||||
assert.deepEqual(run(f).warnings[0].reasons, ['path_overlap']);
|
||||
f.tasks[1].paths = ['src/c.js']; assert.equal(run(f).warnings.length, 0);
|
||||
f.repositories.push({ id: 'other', sources: {} }); f.tasks[1] = task('b', ['src/a.js'], { repoId: 'other' });
|
||||
assert.equal(run(f).warnings.length, 0);
|
||||
});
|
||||
test('leases show owner, expiry, conflicts and do not grant authority', () => {
|
||||
const f = fixture(); f.leases = [
|
||||
{ resource: 'browser:chrome', owner: 'root', expiresAt: '2026-09-08T07:00:00Z' },
|
||||
{ resource: 'browser:chrome', owner: 'worker', expiresAt: '2026-09-08T07:00:00Z' },
|
||||
{ resource: 'browser:chrome', owner: 'old', expiresAt: now }
|
||||
]; const r = run(f);
|
||||
assert.equal(r.leases[2].state, 'expired');
|
||||
assert.deepEqual(r.leaseConflicts, [{ resource: 'browser:chrome', owners: ['root', 'worker'] }]);
|
||||
assert.equal(r.mode, 'read-only'); assert.equal(r.leases[0].authority, 'declared-only');
|
||||
});
|
||||
test('stale heartbeat is not a proven stuck process; absent/future telemetry stays unknown', () => {
|
||||
const f = fixture(); f.tasks = [task('a', [], { heartbeatAt: '2026-09-08T06:00:00Z', pid: 12 }), task('b', [], { heartbeatAt: '2026-09-08T07:00:00Z' }), task('c', [])];
|
||||
const r = run(f); assert.equal(r.tasks[0].heartbeat.state, 'stale'); assert.equal(r.tasks[0].process.state, 'unknown');
|
||||
assert.equal(r.tasks[1].heartbeat.state, 'clock-skew'); assert.equal(r.tasks[2].heartbeat.state, 'unknown');
|
||||
});
|
||||
test('task parents, status and bounded observations survive without source payload', () => {
|
||||
const f = fixture(); f.tasks[1].parentId = 'a'; f.tasks[0].status = 'running'; f.tasks[0].unexpectedSecret = 'CANARY_SECRET';
|
||||
f.repositories[0].sources['lib/b.js'] = 'CANARY_SOURCE';
|
||||
const r = run(f); assert.equal(r.tasks[1].parentId, 'a'); assert.equal(r.tasks[0].status, 'running');
|
||||
assert.ok(!JSON.stringify(r).includes('CANARY')); assert.equal(r.coverage.workingSets, 'declared-paths-only');
|
||||
});
|
||||
test('invalid shapes, IDs, paths, dates and missing repos fail closed', () => {
|
||||
for (const mutate of [
|
||||
f => { f.version = 2; }, f => { f.tasks = null; }, f => { f.tasks.push(f.tasks[0]); },
|
||||
f => { f.tasks[0].paths = ['../escape']; }, f => { f.tasks[0].paths = ['/absolute']; },
|
||||
f => { f.tasks[0].paths = ['C:\\secret']; }, f => { f.tasks[0].paths = ['a/../b']; },
|
||||
f => { f.tasks[0].paths = ['__proto__']; }, f => { f.tasks[0].pid = '-1'; },
|
||||
f => { f.tasks[0].heartbeatAt = 'yesterday'; }, f => { f.tasks[0].repoId = 'absent'; },
|
||||
f => { f.repositories[0].sources = []; }, f => { f.tasks[0].id = '\n'; },
|
||||
f => { f.tasks[0].parentId = 'a'; }, f => { f.tasks = Array(65).fill(f.tasks[0]); },
|
||||
f => { f.leases = [{resource:'chrome',owner:'root',expiresAt:'bad'}]; }
|
||||
]) { const f = fixture(); mutate(f); assert.throws(() => normalizeManifest(f), /Invalid/); }
|
||||
});
|
||||
test('process collection uses metadata-only argv, bounded timeout and no shell', () => {
|
||||
let call; const r = collectResources([task('a', [], { pid: 12 })], { platform: 'darwin', totalmem: () => 1024, freemem: () => 512, execFileSync: (...args) => { call = args; return '12 1 32 01:30 S\n'; } });
|
||||
assert.equal(call[0], 'ps'); assert.deepEqual(call[1], ['-p','12','-o','pid=,ppid=,rss=,etime=,stat=']);
|
||||
assert.equal(call[2].timeout, 2000); assert.equal(call[2].shell, false);
|
||||
assert.equal(r.processes[0].rssBytes, 32768); assert.equal(r.memory.freeBytes, 512);
|
||||
});
|
||||
test('unavailable, empty, malformed and unsupported process snapshots remain explicit', () => {
|
||||
const tasks = [task('a', [], { pid: 12 })];
|
||||
let runnerCalls = 0;
|
||||
const unsupportedDeps = { platform: 'win32', execFileSync: () => { runnerCalls += 1; return ''; } };
|
||||
const unsupported = collectResources(tasks, unsupportedDeps);
|
||||
assert.equal(unsupported.processStatus, 'unsupported');
|
||||
assert.equal(buildInventory({ ...fixture(), tasks }, { now, resources: unsupported }).tasks[0].process.state, 'unknown');
|
||||
// Runner fixtures must select a supported platform independently of the host.
|
||||
assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => { throw new Error('SECRET'); } }).processStatus, 'unavailable');
|
||||
assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => '' }).processStatus, 'ok');
|
||||
assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => 'bad row' }).processStatus, 'unavailable');
|
||||
assert.equal(collectResources([], unsupportedDeps).processStatus, 'not-requested');
|
||||
assert.equal(runnerCalls, 0);
|
||||
});
|
||||
test('live process snapshot enriches matching tasks and marks missing PID as unobserved', () => {
|
||||
const f = fixture(); f.tasks[0].pid = 12; f.tasks[1].pid = 13;
|
||||
const resources = collectResources(f.tasks, { platform: 'linux', execFileSync: () => '12 1 32 01:30 S\n' });
|
||||
const r = buildInventory(f, { now, resources });
|
||||
assert.equal(r.tasks[0].process.state, 'observed'); assert.equal(r.tasks[1].process.state, 'not-observed');
|
||||
});
|
||||
test('task file adapter reads structured status, labels mtime, skips symlinks and rejects oversized JSON', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-test-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, 'worker')); fs.writeFileSync(path.join(dir, 'worker', 'STATUS.md'), '- State: running\n- Updated: 2026-09-08T06:29:00Z\n');
|
||||
fs.symlinkSync(path.join(dir, 'worker'), path.join(dir, 'linked'));
|
||||
const r = collectTaskFiles(dir); assert.equal(r.tasks.length, 1); assert.equal(r.tasks[0].status, 'running');
|
||||
assert.ok(r.tasks[0].statusFileModifiedAt); assert.equal(r.tasks[0].heartbeatAt, '2026-09-08T06:29:00Z');
|
||||
fs.writeFileSync(path.join(dir, 'large.json'), ' '.repeat(1024 * 1024 + 1));
|
||||
assert.throws(() => readJson(path.join(dir, 'large.json')), /limit/);
|
||||
assert.equal(collectTaskFiles(path.join(dir, 'missing')).status, 'unavailable');
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
test('CLI JSON end to end, no output file changes and safe errors', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-cli-'));
|
||||
const cli = path.resolve(__dirname, '../../scripts/coordination-inventory.js');
|
||||
try {
|
||||
const file = path.join(dir,'input.json'); fs.writeFileSync(file, JSON.stringify(fixture()));
|
||||
const r = spawnSync(process.execPath, [cli, '--manifest', file, '--now', now], { encoding:'utf8' });
|
||||
assert.equal(r.status,0,r.stderr); assert.equal(JSON.parse(r.stdout).warnings.length,1);
|
||||
assert.deepEqual(fs.readdirSync(dir),['input.json']);
|
||||
const bad = spawnSync(process.execPath,[cli,'--unknown','CANARY_SECRET'],{encoding:'utf8'});
|
||||
assert.equal(bad.status,1); assert.ok(!bad.stderr.includes('CANARY_SECRET'));
|
||||
const help = spawnSync(process.execPath,[cli,'--help'],{encoding:'utf8'}); assert.equal(help.status,0);
|
||||
} finally { fs.rmSync(dir,{recursive:true,force:true}); }
|
||||
});
|
||||
|
||||
test('prototype-named paths and strict calendar dates are safe', () => {
|
||||
const f = fixture(); f.repositories[0].sources = {}; f.tasks[0].paths = ['toString']; f.tasks[1].paths = ['valueOf'];
|
||||
assert.equal(run(f).warnings.length, 0);
|
||||
for (const invalid of ['2026-02-30T00:00:00Z', '2026-09-08T24:00:00Z']) {
|
||||
f.tasks[0].heartbeatAt = invalid; assert.throws(() => run(f), /Invalid/);
|
||||
}
|
||||
f.tasks[0].heartbeatAt = '2026-09-08T06:00:00.1Z'; assert.equal(run(f).tasks[0].heartbeat.state, 'stale');
|
||||
});
|
||||
test('aggregate comparison budget rejects compact but computationally excessive input', () => {
|
||||
const f = fixture(); f.repositories[0].sources = {};
|
||||
f.tasks = Array.from({length:64}, (_,i) => task(`task${i}`, Array.from({length:128}, (_,j) => `src/${i}/${j}.js`)));
|
||||
assert.throws(() => run(f), /budget/);
|
||||
});
|
||||
test('CLI discovery composes normalized tasks and reports missing telemetry honestly', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-discovery-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir,'worker')); fs.writeFileSync(path.join(dir,'worker','STATUS.md'),'Freeform progress.\n');
|
||||
const r = spawnSync(process.execPath,[path.resolve(__dirname,'../../scripts/coordination-inventory.js'),'--coordination',dir,'--now',now],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr); const report=JSON.parse(r.stdout);
|
||||
assert.equal(report.tasks[0].status,'unknown'); assert.equal(report.tasks[0].heartbeat.state,'unknown');
|
||||
assert.ok(report.tasks[0].statusFileModifiedAt); assert.equal(report.tasks[0].process.state,'unknown');
|
||||
} finally { fs.rmSync(dir,{recursive:true,force:true}); }
|
||||
});
|
||||
test('source snippets are bounded before invoking inherited regex extractor', () => {
|
||||
const f = fixture(); f.repositories[0].sources = { 'a.js': `import ${' '.repeat(32000)}x` }; f.tasks=[];
|
||||
assert.throws(() => run(f), /Invalid/);
|
||||
f.repositories[0].sources = Object.fromEntries(Array.from({length:33},(_,i) => [`${i}.js`, ' '.repeat(1024)]));
|
||||
assert.throws(() => run(f), /Invalid/);
|
||||
});
|
||||
test('maximum accepted whitespace snippets complete within bounded subprocess timeout', () => {
|
||||
const code = `const {buildInventory}=require('./scripts/lib/coordination-inventory');
|
||||
const source='import '+' '.repeat(1016)+'x';
|
||||
const sources=Object.fromEntries(Array.from({length:32},(_,i)=>[i+'.js',source]));
|
||||
const r=buildInventory({version:1,repositories:[{id:'r',sources}],tasks:[{id:'a',repoId:'r',paths:['0.js']},{id:'b',repoId:'r',paths:['1.js']}]});
|
||||
if(r.warnings.length) process.exitCode=1;`;
|
||||
const r=spawnSync(process.execPath,['-e',code],{cwd:path.resolve(__dirname,'../..'),encoding:'utf8',timeout:2000});
|
||||
assert.equal(r.status,0,r.error?.message || r.stderr);
|
||||
});
|
||||
test('bounded import parsing preserves supported JS and TS import forms', () => {
|
||||
const { buildDependencyGraphFromSources } = require('../../scripts/lib/agent-proximity/graph');
|
||||
for (const source of ["import './b'", "import b from './b'", "import { b as c } from './b'", "import * as b from './b'", "import b, { c } from './b'", "import type { B } from './b'", "import {\n b\n} from './b'", "import('./b')"]) {
|
||||
assert.deepEqual(buildDependencyGraphFromSources({'a.js':source,'b.js':''}).adjacency['a.js'],['b.js']);
|
||||
}
|
||||
});
|
||||
+269
-103
@@ -25,32 +25,43 @@ async function test(name, fn) {
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.stack || error.message}`);
|
||||
console.log(` ${error.mcpDiagnostic ? JSON.stringify(error.mcpDiagnostic) : error.stack || error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(extraEnv = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
return {
|
||||
root,
|
||||
projectRoot,
|
||||
env: Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
|
||||
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
|
||||
ECC_MEMORY_HARNESS: 'claude',
|
||||
...extraEnv,
|
||||
}).filter(([, value]) => typeof value === 'string')
|
||||
),
|
||||
};
|
||||
try {
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
return {
|
||||
root,
|
||||
projectRoot,
|
||||
env: Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
|
||||
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
|
||||
ECC_MEMORY_HARNESS: 'claude',
|
||||
ECC_MEMORY_ALLOW_USER_SCOPE: '0',
|
||||
...extraEnv,
|
||||
}).filter(([, value]) => typeof value === 'string')
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
try { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
catch {
|
||||
const failure = new Error('MCP fixture cleanup failed', { cause: error });
|
||||
failure.mcpCleanupFailure = 'fixture_removal_error';
|
||||
throw failure;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTextResult(result) {
|
||||
@@ -60,105 +71,260 @@ function parseTextResult(result) {
|
||||
}
|
||||
|
||||
async function withClient(fn, options = {}) {
|
||||
const fixture = createFixture(options.env);
|
||||
const child = spawn(process.execPath, [options.server || SERVER], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const started = Date.now();
|
||||
const pending = new Map();
|
||||
const mode = options.env?.ECC_MEMORY_ALLOW_USER_SCOPE === '1' ? 'allow' : 'deny';
|
||||
let fixture;
|
||||
let child;
|
||||
let phase = 'setup';
|
||||
let nextId = 1;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdout = Buffer.alloc(0);
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let closed = false;
|
||||
let tearingDown = false;
|
||||
let transportError;
|
||||
let primaryError;
|
||||
let primaryFailed = false;
|
||||
let failureKind;
|
||||
let failureElapsedMs;
|
||||
let teardownStarted;
|
||||
let failurePhase;
|
||||
let cleanupFailure;
|
||||
let killStatus = 'not_attempted';
|
||||
let notifyClose;
|
||||
const closePromise = new Promise(resolve => { notifyClose = resolve; });
|
||||
let rejectTransport;
|
||||
const transportFailure = new Promise((_, reject) => { rejectTransport = reject; });
|
||||
// The child may fail before the initialize or callback race is installed.
|
||||
transportFailure.catch(() => {});
|
||||
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString('utf8');
|
||||
let newlineIndex = stdout.indexOf('\n');
|
||||
while (newlineIndex >= 0) {
|
||||
const line = stdout.slice(0, newlineIndex);
|
||||
stdout = stdout.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
const message = JSON.parse(line);
|
||||
const bounded = value => Math.min(2147483647, Math.max(0, Math.trunc(value)));
|
||||
const safeCode = error => [
|
||||
'EPIPE', 'ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'ECONNRESET',
|
||||
'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END', 'ERR_ASSERTION',
|
||||
].includes(error?.code) ? error.code : null;
|
||||
const diagnostic = () => ({
|
||||
phase: failurePhase || phase,
|
||||
mode,
|
||||
reason: failureKind || cleanupFailure || 'assertion_or_callback',
|
||||
failureElapsedMs: failureElapsedMs ?? null,
|
||||
teardownElapsedMs: bounded(Date.now() - teardownStarted),
|
||||
elapsedMs: bounded(Date.now() - started),
|
||||
stdoutBytes,
|
||||
stderrBytes,
|
||||
pendingRequests: pending.size,
|
||||
childStarted: Boolean(child?.pid),
|
||||
childClosed: closed,
|
||||
exitCode: Number.isInteger(child?.exitCode) ? child.exitCode : null,
|
||||
signal: ['SIGTERM', 'SIGKILL', 'SIGINT'].includes(child?.signalCode) ? child.signalCode : null,
|
||||
errorCode: safeCode(primaryError),
|
||||
cleanupFailure: cleanupFailure || null,
|
||||
killStatus,
|
||||
});
|
||||
function settleAll(error) {
|
||||
for (const waiter of pending.values()) waiter.reject(error);
|
||||
pending.clear();
|
||||
}
|
||||
function fail(kind, cause) {
|
||||
if (tearingDown) {
|
||||
cleanupFailure ||= kind;
|
||||
return;
|
||||
}
|
||||
if (transportError) return;
|
||||
transportError = new Error(`MCP test client ${kind}`);
|
||||
if (safeCode(cause)) transportError.code = safeCode(cause);
|
||||
failurePhase = phase;
|
||||
failureKind = kind;
|
||||
settleAll(transportError);
|
||||
rejectTransport(transportError);
|
||||
}
|
||||
function send(message) {
|
||||
if (transportError) throw transportError;
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify(message)}\n`, error => {
|
||||
if (error) fail('stdin_write_error', error);
|
||||
});
|
||||
} catch (error) {
|
||||
fail('stdin_write_error', error);
|
||||
throw transportError;
|
||||
}
|
||||
}
|
||||
function request(method, params = {}) {
|
||||
const id = nextId++;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
if (transportError || tearingDown || closed) {
|
||||
reject(transportError || new Error('MCP test client is closed'));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
fail('request_timeout');
|
||||
}, 5000);
|
||||
function settle(fn, value) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
fn(value);
|
||||
}
|
||||
pending.set(id, {
|
||||
resolve: value => settle(resolve, value),
|
||||
reject: error => settle(reject, error),
|
||||
});
|
||||
send({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
// Teardown rejects abandoned requests too, without an unhandled rejection.
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
try {
|
||||
fixture = createFixture(options.env);
|
||||
phase = 'spawn';
|
||||
child = spawn(process.execPath, [options.server || SERVER], {
|
||||
cwd: fixture.projectRoot,
|
||||
env: fixture.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
child.on('error', error => fail('child_error', error));
|
||||
child.on('exit', () => {
|
||||
if (!tearingDown) fail('child_exit');
|
||||
});
|
||||
child.once('close', () => {
|
||||
closed = true;
|
||||
notifyClose();
|
||||
if (!tearingDown) fail('child_close');
|
||||
});
|
||||
for (const stream of ['stdin', 'stdout', 'stderr']) {
|
||||
child[stream].on('error', error => fail(`${stream}_error`, error));
|
||||
}
|
||||
child.stdout.on('end', () => { if (!tearingDown) fail('stdout_end'); });
|
||||
for (const stream of ['stdin', 'stdout']) {
|
||||
child[stream].on('close', () => { if (!tearingDown) fail(`${stream}_close`); });
|
||||
}
|
||||
child.stderr.on('data', chunk => {
|
||||
stderrBytes = bounded(stderrBytes + chunk.length);
|
||||
});
|
||||
child.stdout.on('data', chunk => {
|
||||
stdoutBytes = bounded(stdoutBytes + chunk.length);
|
||||
if (transportError || tearingDown) return;
|
||||
// Decode complete lines, so a UTF-8 character split across chunks survives.
|
||||
stdout = Buffer.concat([stdout, chunk]);
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = stdout.indexOf(10)) >= 0) {
|
||||
if (newlineIndex > 1024 * 1024) { fail('oversized_frame'); return; }
|
||||
const line = stdout.subarray(0, newlineIndex).toString('utf8');
|
||||
stdout = stdout.subarray(newlineIndex + 1);
|
||||
if (!line.trim()) continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
if (!message || message.jsonrpc !== '2.0' || !Number.isInteger(message.id)
|
||||
|| (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))
|
||||
|| (Object.hasOwn(message, 'error') && (!message.error
|
||||
|| !Number.isInteger(message.error.code) || typeof message.error.message !== 'string'))) {
|
||||
fail('invalid_frame');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
fail('malformed_frame');
|
||||
return;
|
||||
}
|
||||
const waiter = pending.get(message.id);
|
||||
if (waiter) {
|
||||
pending.delete(message.id);
|
||||
if (message.error) {
|
||||
// Existing authorization/protocol assertions inspect this RPC error.
|
||||
// The test logger emits only mcpDiagnostic when it escapes the helper.
|
||||
waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
|
||||
} else {
|
||||
waiter.resolve(message.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
newlineIndex = stdout.indexOf('\n');
|
||||
if (stdout.length > 1024 * 1024) fail('oversized_frame');
|
||||
});
|
||||
|
||||
phase = 'initialize';
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
|
||||
});
|
||||
phase = 'protocol';
|
||||
assert.strictEqual(initialized.protocolVersion, '2025-11-25');
|
||||
phase = 'notification';
|
||||
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
||||
const client = {
|
||||
listTools: () => request('tools/list'),
|
||||
listToolsRaw: params => request('tools/list', params),
|
||||
callTool: ({ name, arguments: toolArguments }) => request(
|
||||
'tools/call',
|
||||
{ name, arguments: toolArguments }
|
||||
),
|
||||
callToolRaw: params => request('tools/call', params),
|
||||
};
|
||||
phase = 'callback';
|
||||
await Promise.race([Promise.resolve().then(() => fn(client, fixture)), transportFailure]);
|
||||
if (transportError) throw transportError;
|
||||
assert.strictEqual(pending.size, 0, 'MCP callback must await its requests');
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
primaryFailed = true;
|
||||
failurePhase ||= phase;
|
||||
failureElapsedMs = bounded(Date.now() - started);
|
||||
if (error?.mcpCleanupFailure === 'fixture_removal_error') {
|
||||
cleanupFailure ||= 'fixture_removal_error';
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
function send(message) {
|
||||
child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function request(method, params = {}) {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`));
|
||||
}, 5000);
|
||||
pending.set(id, {
|
||||
resolve: value => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
},
|
||||
reject: error => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
send({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
const initialized = await request('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
|
||||
});
|
||||
assert.strictEqual(initialized.protocolVersion, '2025-11-25');
|
||||
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
||||
|
||||
const client = {
|
||||
listTools: () => request('tools/list'),
|
||||
listToolsRaw: params => request('tools/list', params),
|
||||
callTool: ({ name, arguments: toolArguments }) => request(
|
||||
'tools/call',
|
||||
{ name, arguments: toolArguments }
|
||||
),
|
||||
callToolRaw: params => request('tools/call', params),
|
||||
};
|
||||
|
||||
try {
|
||||
await fn(client, fixture);
|
||||
} finally {
|
||||
child.stdin.end();
|
||||
await new Promise(resolve => {
|
||||
if (child.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
tearingDown = true;
|
||||
teardownStarted = Date.now();
|
||||
phase = 'teardown';
|
||||
settleAll(new Error('MCP test client is closing'));
|
||||
stdout = Buffer.alloc(0);
|
||||
if (child && !closed) {
|
||||
// Keep the original total 2000 ms budget. Reserve its latter half for
|
||||
// direct-child termination and stdio close, including on Windows.
|
||||
let killTimer;
|
||||
let deadlineTimer;
|
||||
function terminate() {
|
||||
try { killStatus = child.kill() ? 'requested' : 'not_sent'; }
|
||||
catch { killStatus = 'error'; }
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
resolve();
|
||||
}, 2000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
const deadline = new Promise(resolve => {
|
||||
deadlineTimer = setTimeout(resolve, 2000);
|
||||
killTimer = setTimeout(terminate, 1000);
|
||||
});
|
||||
});
|
||||
fs.rmSync(fixture.root, { recursive: true, force: true });
|
||||
try {
|
||||
try { child.stdin.end(); }
|
||||
catch {
|
||||
cleanupFailure ||= 'stdin_end_error';
|
||||
clearTimeout(killTimer);
|
||||
terminate();
|
||||
}
|
||||
await Promise.race([closePromise, deadline]);
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
clearTimeout(deadlineTimer);
|
||||
}
|
||||
if (!closed) cleanupFailure ||= 'child_close_timeout';
|
||||
}
|
||||
if (fixture && (!child || closed)) {
|
||||
try { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
||||
catch { cleanupFailure ||= 'fixture_removal_error'; }
|
||||
}
|
||||
}
|
||||
if (primaryFailed || cleanupFailure) {
|
||||
if (!primaryFailed) primaryError = new Error('MCP test client cleanup failed');
|
||||
// Keep the primary assertion/RPC/callback error; cleanup must not replace it.
|
||||
// A wrapper retains non-extensible or non-Error thrown values as its cause.
|
||||
if (!primaryError || typeof primaryError !== 'object' || !Object.isExtensible(primaryError)
|
||||
|| Object.getOwnPropertyDescriptor(primaryError, 'mcpDiagnostic')?.configurable === false
|
||||
|| Object.getOwnPropertyDescriptor(primaryError, 'mcpCleanupFailure')?.configurable === false) {
|
||||
primaryError = new Error('MCP test client failed', { cause: primaryError });
|
||||
}
|
||||
Object.defineProperty(primaryError, 'mcpDiagnostic', { value: diagnostic(), configurable: true });
|
||||
if (cleanupFailure) {
|
||||
Object.defineProperty(primaryError, 'mcpCleanupFailure', { value: cleanupFailure, configurable: true });
|
||||
}
|
||||
throw primaryError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user