feat(control-pane): interactive JIT board — claim/move cards from the webapp

The board was read-only; you can now drive the agent+human JIT workflow from the
local control pane.

- New shared scripts/lib/control-pane/work-item-mutations.js (claimWorkItem,
  moveWorkItem) so the CLI and server never diverge; work-items.js claim now
  delegates to it.
- server.js: gated POST /api/work-items/:id/claim and /:id/move (localhost-only,
  honors --read-only with 403). Claim sets owner + assigneeKind and moves to
  running; move retargets the kanban lane.
- ui.js: per-card Claim (on unassigned cards) + lane buttons that POST and
  refresh; 15s live auto-refresh (paused when the tab is hidden).
- Tests: interactive claim/move endpoints, read-only 403, invalid-lane 400, and
  snapshot reflects mutations.

Full suite 2845/2845; lint green.
This commit is contained in:
Affaan Mustafa 2026-06-18 18:16:46 -04:00
parent 7fd4ba95ae
commit 607ab02b1f
5 changed files with 671 additions and 417 deletions

View File

@ -8,6 +8,20 @@ const { spawn } = require('child_process');
const { buildControlPaneAction } = require('./actions'); const { buildControlPaneAction } = require('./actions');
const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state'); const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state');
const { renderControlPaneHtml } = require('./ui'); const { renderControlPaneHtml } = require('./ui');
const { claimWorkItem, moveWorkItem } = require('./work-item-mutations');
// Run a single write against the local work-item store, then close it. Kept
// thin so the loopback-only server can mutate the JIT board without holding a
// long-lived handle.
async function withStateStore(stateDbPath, fn) {
const { createStateStore } = require('../state-store');
const store = await createStateStore({ dbPath: stateDbPath });
try {
return await fn(store);
} finally {
store.close();
}
}
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']); const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
@ -55,7 +69,7 @@ function usage() {
' --state-db <path> Read agent work items from an ECC state-store database', ' --state-db <path> Read agent work items from an ECC state-store database',
' --read-only Disable action execution endpoints', ' --read-only Disable action execution endpoints',
' --no-open Do not open a browser after the server starts', ' --no-open Do not open a browser after the server starts',
' --help Show this help', ' --help Show this help'
].join('\n'); ].join('\n');
} }
@ -92,7 +106,7 @@ function parseArgs(argv) {
configPath: valueAfter(args, '--config'), configPath: valueAfter(args, '--config'),
query: valueAfter(args, '--query') || '', query: valueAfter(args, '--query') || '',
openBrowser: !args.includes('--no-open'), openBrowser: !args.includes('--no-open'),
allowActions: !args.includes('--read-only'), allowActions: !args.includes('--read-only')
}; };
} }
@ -100,7 +114,7 @@ function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2); const body = JSON.stringify(payload, null, 2);
res.writeHead(statusCode, { res.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8', 'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store', 'cache-control': 'no-store'
}); });
res.end(`${body}\n`); res.end(`${body}\n`);
} }
@ -108,7 +122,7 @@ function sendJson(res, statusCode, payload) {
function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') { function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') {
res.writeHead(statusCode, { res.writeHead(statusCode, {
'content-type': contentType, 'content-type': contentType,
'cache-control': 'no-store', 'cache-control': 'no-store'
}); });
res.end(body); res.end(body);
} }
@ -135,7 +149,7 @@ function runAction(action, options = {}) {
const child = spawn(action.command, action.args, { const child = spawn(action.command, action.args, {
cwd: action.cwd, cwd: action.cwd,
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe']
}); });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@ -163,7 +177,7 @@ function runAction(action, options = {}) {
code: null, code: null,
error: error.message, error: error.message,
stdout: boundedOutput(stdout), stdout: boundedOutput(stdout),
stderr: boundedOutput(stderr), stderr: boundedOutput(stderr)
}); });
}); });
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
@ -177,7 +191,7 @@ function runAction(action, options = {}) {
code, code,
signal, signal,
stdout: boundedOutput(stdout), stdout: boundedOutput(stdout),
stderr: boundedOutput(stderr), stderr: boundedOutput(stderr)
}); });
}); });
}); });
@ -193,7 +207,7 @@ function createControlPaneServer(options = {}) {
configPath: options.configPath, configPath: options.configPath,
dbPath: options.dbPath, dbPath: options.dbPath,
stateDbPath: options.stateDbPath, stateDbPath: options.stateDbPath,
env: options.env || process.env, env: options.env || process.env
}); });
const baseQuery = options.query || ''; const baseQuery = options.query || '';
const allowedHostnames = buildAllowedHostnames(host); const allowedHostnames = buildAllowedHostnames(host);
@ -232,7 +246,7 @@ function createControlPaneServer(options = {}) {
repoRoot, repoRoot,
dbPath: resolvedConfig.dbPath, dbPath: resolvedConfig.dbPath,
stateDbPath: resolvedConfig.stateDbPath, stateDbPath: resolvedConfig.stateDbPath,
allowActions, allowActions
}); });
return; return;
} }
@ -245,7 +259,7 @@ function createControlPaneServer(options = {}) {
config: resolvedConfig, config: resolvedConfig,
query: requestUrl.searchParams.get('query') || baseQuery, query: requestUrl.searchParams.get('query') || baseQuery,
limit: requestUrl.searchParams.get('limit') || 12, limit: requestUrl.searchParams.get('limit') || 12,
allowActions, allowActions
}); });
sendJson(res, 200, snapshot); sendJson(res, 200, snapshot);
return; return;
@ -256,7 +270,7 @@ function createControlPaneServer(options = {}) {
if (!allowActions) { if (!allowActions) {
sendJson(res, 403, { sendJson(res, 403, {
ok: false, ok: false,
error: 'Control-pane action execution is disabled by --read-only.', error: 'Control-pane action execution is disabled by --read-only.'
}); });
return; return;
} }
@ -265,7 +279,7 @@ function createControlPaneServer(options = {}) {
const action = buildControlPaneAction(decodeURIComponent(actionMatch[1]), { const action = buildControlPaneAction(decodeURIComponent(actionMatch[1]), {
repoRoot, repoRoot,
query: body.query || baseQuery, query: body.query || baseQuery,
limit: body.limit || 25, limit: body.limit || 25
}); });
if (!action.executable) { if (!action.executable) {
@ -273,7 +287,7 @@ function createControlPaneServer(options = {}) {
ok: false, ok: false,
action: action.id, action: action.id,
error: 'This action is copy-only and cannot be executed from the browser.', error: 'This action is copy-only and cannot be executed from the browser.',
commandLine: action.commandLine, commandLine: action.commandLine
}); });
return; return;
} }
@ -281,16 +295,47 @@ function createControlPaneServer(options = {}) {
const result = await runAction(action); const result = await runAction(action);
sendJson(res, result.ok ? 200 : 500, { sendJson(res, result.ok ? 200 : 500, {
...result, ...result,
commandLine: action.commandLine, commandLine: action.commandLine
}); });
return; return;
} }
// Interactive JIT board: claim / move a work item from the browser.
const claimMatch = requestUrl.pathname.match(/^\/api\/work-items\/([^/]+)\/claim$/);
const moveMatch = requestUrl.pathname.match(/^\/api\/work-items\/([^/]+)\/move$/);
if (req.method === 'POST' && (claimMatch || moveMatch)) {
if (!allowActions) {
sendJson(res, 403, {
ok: false,
error: 'Board edits are disabled by --read-only.'
});
return;
}
const id = decodeURIComponent((claimMatch || moveMatch)[1]);
const body = await readRequestJson(req);
try {
const result = await withStateStore(resolvedConfig.stateDbPath, store =>
claimMatch
? claimWorkItem(store, {
id,
owner: body.owner,
assigneeKind: body.as || body.assigneeKind,
sessionId: body.sessionId
})
: moveWorkItem(store, { id, lane: body.lane })
);
sendJson(res, 200, { ok: true, ...result });
} catch (mutationError) {
sendJson(res, 400, { ok: false, error: mutationError.message });
}
return;
}
sendJson(res, 404, { ok: false, error: 'not found' }); sendJson(res, 404, { ok: false, error: 'not found' });
} catch (error) { } catch (error) {
sendJson(res, 500, { sendJson(res, 500, {
ok: false, ok: false,
error: error.message, error: error.message
}); });
} }
}); });
@ -319,7 +364,7 @@ function createControlPaneServer(options = {}) {
else resolve(); else resolve();
}); });
}); });
}, }
}; };
} }
@ -330,5 +375,5 @@ module.exports = {
isAllowedHostHeader, isAllowedHostHeader,
isAllowedOrigin, isAllowedOrigin,
buildAllowedHostnames, buildAllowedHostnames,
usage, usage
}; };

View File

@ -519,12 +519,24 @@ function renderControlPaneHtml() {
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || ''; const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
const assigneeKind = item.assigneeKind || 'unassigned'; const assigneeKind = item.assigneeKind || 'unassigned';
const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned'; const owner = item.assignee || item.owner || (assigneeKind === 'unassigned' ? 'unassigned (JIT)' : item.source) || 'unassigned';
const idJs = "'" + String(item.id).replace(/'/g, "\\'") + "'";
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane => {
const call = 'eccMoveItem(' + idJs + ", '" + lane + "')";
return '<button type="button" onclick="' + call + '">' + escapeHtml(lane) + '</button>';
}).join('');
const controls = state.allowActions
? '<div class="row">'
+ (assigneeKind === 'unassigned' ? '<button type="button" onclick="eccClaimItem(' + idJs + ')">Claim</button>' : '')
+ moveButtons
+ '</div>'
: '';
return '<div class="work-item">' + return '<div class="work-item">' +
'<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' + '<div class="row"><strong>' + escapeHtml(item.title || item.id) + '</strong>' + statePill(item.kanbanState || item.status) + '</div>' +
'<div class="subtle">[' + escapeHtml(assigneeKind) + '] ' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' + '<div class="subtle">[' + escapeHtml(assigneeKind) + '] ' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '</div>' +
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') + (branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') + (mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') + (blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
controls +
'</div>'; '</div>';
}).join(''); }).join('');
} }
@ -606,7 +618,8 @@ function renderControlPaneHtml() {
const snapshot = await readJsonResponse(response); const snapshot = await readJsonResponse(response);
$('#query').value = snapshot.knowledge.query || state.query; $('#query').value = snapshot.knowledge.query || state.query;
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing'; $('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
$('#action-status').textContent = snapshot.execution.allowActions ? 'local allowlist' : 'read-only'; state.allowActions = Boolean(snapshot.execution.allowActions);
$('#action-status').textContent = state.allowActions ? 'local allowlist' : 'read-only';
renderMetrics(snapshot.summary); renderMetrics(snapshot.summary);
renderSessions(snapshot.sessions); renderSessions(snapshot.sessions);
renderWorkItems(snapshot.workItems); renderWorkItems(snapshot.workItems);
@ -627,6 +640,38 @@ function renderControlPaneHtml() {
$('#refresh').addEventListener('click', () => { $('#refresh').addEventListener('click', () => {
load().catch(error => showError('#app', error)); load().catch(error => showError('#app', error));
}); });
async function postWorkItem(pathSuffix, payload) {
const response = await fetch('/api/work-items/' + pathSuffix, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload || {})
});
const result = await readJsonResponse(response);
if (!result.ok) throw new Error(result.error || 'request failed');
await load();
}
window.eccClaimItem = function (id) {
if (!state.allowActions) return;
const owner = window.prompt('Claim "' + id + '" as (owner name):');
if (!owner) return;
const as = (window.prompt("Owner kind: 'agent' or 'human'", 'human') || '').trim().toLowerCase();
postWorkItem(encodeURIComponent(id) + '/claim', { owner: owner.trim(), as: as === 'agent' ? 'agent' : 'human' })
.catch(error => showError('#app', error));
};
window.eccMoveItem = function (id, lane) {
if (!state.allowActions) return;
postWorkItem(encodeURIComponent(id) + '/move', { lane })
.catch(error => showError('#app', error));
};
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
setInterval(() => {
if (document.hidden) return;
load().catch(() => {});
}, 15000);
load().catch(error => showError('#app', error)); load().catch(error => showError('#app', error));
</script> </script>
</body> </body>

View File

@ -0,0 +1,121 @@
'use strict';
/**
* Shared work-item mutation helpers for the agent+human JIT board.
*
* Used by both the `work-items.js` CLI (`claim`) and the control-pane local
* server (interactive claim / move), so the two surfaces never diverge.
*/
const DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']);
const PRIORITY_RANK = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 };
// Kanban lanes the board renders, and the canonical status each maps to on a move.
const LANE_TO_STATUS = {
ready: 'open',
running: 'running',
blocked: 'blocked',
done: 'done'
};
const VALID_LANES = new Set(Object.keys(LANE_TO_STATUS));
const VALID_ASSIGNEE_KINDS = new Set(['agent', 'human']);
function isOpenStatus(status) {
return !DONE_STATUSES.has(
String(status || '')
.trim()
.toLowerCase()
);
}
function priorityRank(priority) {
return PRIORITY_RANK[String(priority || '').toLowerCase()] ?? 2;
}
/**
* Resolve which work item a claim targets: an explicit id, otherwise the
* highest-priority unassigned open item (the JIT pickup queue).
*/
function selectClaimTarget(store, { id } = {}) {
if (id) {
const item = store.getWorkItemById(id);
if (!item) {
throw new Error(`Work item not found: ${id}`);
}
return item;
}
const { items } = store.listWorkItems({ limit: 100 });
return items.filter(item => !item.owner && isOpenStatus(item.status)).sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority))[0] || null;
}
/**
* Claim an unassigned work item for an agent or human. Sets the owner (and
* optional assigneeKind) and moves the card to running unless an explicit
* status is supplied. Returns { claimed, item } or { claimed: false, reason }.
*/
function claimWorkItem(store, { id, owner, assigneeKind, sessionId, status } = {}) {
if (!owner) {
throw new Error('claim requires an owner.');
}
const kind = assigneeKind ? String(assigneeKind).toLowerCase() : null;
if (kind && !VALID_ASSIGNEE_KINDS.has(kind)) {
throw new Error("assigneeKind must be 'agent' or 'human'.");
}
const target = selectClaimTarget(store, { id });
if (!target) {
return { claimed: false, reason: 'no-unassigned-open-items' };
}
if (!isOpenStatus(target.status)) {
throw new Error(`Work item ${target.id} is already done; cannot claim.`);
}
const metadata = { ...(target.metadata || {}) };
if (kind) {
metadata.assigneeKind = kind;
}
const item = store.upsertWorkItem({
...target,
owner,
sessionId: sessionId ?? target.sessionId ?? null,
status: status ?? 'running',
metadata,
updatedAt: new Date().toISOString()
});
return { claimed: true, item };
}
/**
* Move a work item to a kanban lane (ready | running | blocked | done).
*/
function moveWorkItem(store, { id, lane } = {}) {
if (!id) {
throw new Error('move requires a work item id.');
}
const laneKey = String(lane || '')
.trim()
.toLowerCase();
if (!VALID_LANES.has(laneKey)) {
throw new Error(`Invalid lane '${lane}'. Expected one of ${[...VALID_LANES].join(', ')}.`);
}
const target = store.getWorkItemById(id);
if (!target) {
throw new Error(`Work item not found: ${id}`);
}
const item = store.upsertWorkItem({
...target,
status: LANE_TO_STATUS[laneKey],
updatedAt: new Date().toISOString()
});
return { moved: true, item };
}
module.exports = {
DONE_STATUSES,
LANE_TO_STATUS,
VALID_LANES,
VALID_ASSIGNEE_KINDS,
isOpenStatus,
priorityRank,
selectClaimTarget,
claimWorkItem,
moveWorkItem
};

View File

@ -4,6 +4,7 @@
const os = require('os'); const os = require('os');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const { createStateStore } = require('./lib/state-store'); const { createStateStore } = require('./lib/state-store');
const { claimWorkItem } = require('./lib/control-pane/work-item-mutations');
const VALUE_FLAGS = new Set([ const VALUE_FLAGS = new Set([
'--as', '--as',
@ -388,73 +389,23 @@ function printGithubSyncResult(payload) {
} }
} }
const CLAIM_DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']); // Thin CLI adapter over the shared claimWorkItem helper, preserving the
const CLAIM_PRIORITY_RANK = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 }; // flag-specific error messages (--owner / --as) for the command line.
function claimWorkItemCli(store, options) {
function isOpenWorkItemStatus(status) { if (!options.owner) {
return !CLAIM_DONE_STATUSES.has(
String(status || '')
.trim()
.toLowerCase()
);
}
// Resolve which work item a `claim` targets: an explicit id, otherwise the
// highest-priority unassigned open item (the JIT pickup queue the control-pane
// board surfaces).
function selectClaimTarget(store, options) {
const explicitId = resolveWorkItemId(options);
if (explicitId) {
const item = store.getWorkItemById(explicitId);
if (!item) {
throw new Error(`Work item not found: ${explicitId}`);
}
return item;
}
const { items } = store.listWorkItems({ limit: 100 });
return (
items
.filter(item => !item.owner && isOpenWorkItemStatus(item.status))
.sort((a, b) => {
const ra = CLAIM_PRIORITY_RANK[String(a.priority || '').toLowerCase()] ?? 2;
const rb = CLAIM_PRIORITY_RANK[String(b.priority || '').toLowerCase()] ?? 2;
return ra - rb;
})[0] || null
);
}
// Claim an unassigned work item for an agent or human — the just-in-time pickup
// primitive. Sets the owner (+ optional assigneeKind) and moves the card to
// running so the board reflects that work has started.
function claimWorkItem(store, options) {
const owner = options.owner;
if (!owner) {
throw new Error('claim requires --owner <name>.'); throw new Error('claim requires --owner <name>.');
} }
const assigneeKind = options.claimAs ? String(options.claimAs).toLowerCase() : null; const assigneeKind = options.claimAs ? String(options.claimAs).toLowerCase() : null;
if (assigneeKind && assigneeKind !== 'agent' && assigneeKind !== 'human') { if (assigneeKind && assigneeKind !== 'agent' && assigneeKind !== 'human') {
throw new Error("--as must be 'agent' or 'human'."); throw new Error("--as must be 'agent' or 'human'.");
} }
const target = selectClaimTarget(store, options); return claimWorkItem(store, {
if (!target) { id: resolveWorkItemId(options),
return { claimed: false, reason: 'no-unassigned-open-items' }; owner: options.owner,
} assigneeKind,
if (!isOpenWorkItemStatus(target.status)) { sessionId: options.sessionId,
throw new Error(`Work item ${target.id} is already done; cannot claim.`); status: options.status
}
const metadata = { ...(target.metadata || {}) };
if (assigneeKind) {
metadata.assigneeKind = assigneeKind;
}
const item = store.upsertWorkItem({
...target,
owner,
sessionId: options.sessionId ?? target.sessionId ?? null,
status: options.status ?? 'running',
metadata,
updatedAt: new Date().toISOString()
}); });
return { claimed: true, item };
} }
async function main() { async function main() {
@ -538,7 +489,7 @@ async function main() {
} }
if (options.command === 'claim') { if (options.command === 'claim') {
const result = claimWorkItem(store, options); const result = claimWorkItemCli(store, options);
if (options.json) { if (options.json) {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} else if (!result.claimed) { } else if (!result.claimed) {

View File

@ -11,17 +11,8 @@ const { spawn, spawnSync } = require('child_process');
const initSqlJs = require('sql.js'); const initSqlJs = require('sql.js');
const { const { createControlPaneServer, parseArgs, runAction, isAllowedHostHeader, isAllowedOrigin, buildAllowedHostnames } = require('../../scripts/lib/control-pane/server');
createControlPaneServer, const { main: runControlPaneCli } = require('../../scripts/control-pane');
parseArgs,
runAction,
isAllowedHostHeader,
isAllowedOrigin,
buildAllowedHostnames,
} = require('../../scripts/lib/control-pane/server');
const {
main: runControlPaneCli,
} = require('../../scripts/control-pane');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'control-pane.js');
const REPO_ROOT = path.join(__dirname, '..', '..'); const REPO_ROOT = path.join(__dirname, '..', '..');
@ -142,238 +133,253 @@ async function runTests() {
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
if (await test('parses CLI arguments for local-only serving', async () => { if (
const parsed = parseArgs([ await test('parses CLI arguments for local-only serving', async () => {
'node', const parsed = parseArgs([
'scripts/control-pane.js', 'node',
'--host', 'scripts/control-pane.js',
'127.0.0.1', '--host',
'--port', '127.0.0.1',
'8788', '--port',
'--db', '8788',
'/tmp/ecc2.db', '--db',
'--state-db', '/tmp/ecc2.db',
'/tmp/ecc-state.db', '--state-db',
'--query', '/tmp/ecc-state.db',
'Hermes memory', '--query',
'--no-open', 'Hermes memory',
]); '--no-open'
]);
assert.strictEqual(parsed.host, '127.0.0.1'); assert.strictEqual(parsed.host, '127.0.0.1');
assert.strictEqual(parsed.port, 8788); assert.strictEqual(parsed.port, 8788);
assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db'); assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db');
assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db'); assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db');
assert.strictEqual(parsed.query, 'Hermes memory'); assert.strictEqual(parsed.query, 'Hermes memory');
assert.strictEqual(parsed.openBrowser, false); assert.strictEqual(parsed.openBrowser, false);
})) passed++; else failed++; })
)
passed++;
else failed++;
if (await test('rejects invalid CLI port values', async () => { if (
assert.throws( await test('rejects invalid CLI port values', async () => {
() => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), /Invalid --port value/);
/Invalid --port value/ assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), /Invalid --port value/);
); })
assert.throws( )
() => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), passed++;
/Invalid --port value/ else failed++;
);
})) passed++; else failed++;
if (await test('rejects missing state database path values', async () => { if (
assert.throws( await test('rejects missing state database path values', async () => {
() => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), /Invalid --state-db value/);
/Invalid --state-db value/ assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), /Invalid --state-db value/);
); })
assert.throws( )
() => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), passed++;
/Invalid --state-db value/ else failed++;
);
})) passed++; else failed++;
if (await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => { if (
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-')); await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => {
const dbPath = path.join(tempDir, 'ecc2.db'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-'));
const dbPath = path.join(tempDir, 'ecc2.db');
try {
await writeMinimalDatabase(dbPath);
const app = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
dbPath,
repoRoot: REPO_ROOT,
query: 'control pane',
allowActions: false,
});
await app.listen();
try { try {
const html = await fetchLocal(`${app.url}/`).then(response => response.text()); await writeMinimalDatabase(dbPath);
assert.ok(html.includes('ECC Control Pane')); const app = await createControlPaneServer({
assert.ok(html.includes('id="app"')); host: '127.0.0.1',
assert.ok(html.includes('id="work-items"')); port: 0,
assert.ok(html.includes('function renderWorkItems')); dbPath,
assert.ok(html.includes('function showError')); repoRoot: REPO_ROOT,
assert.ok(html.includes('response.ok')); query: 'control pane',
allowActions: false
});
const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json()); await app.listen();
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1'); try {
assert.strictEqual(snapshot.summary.totalSessions, 1); const html = await fetchLocal(`${app.url}/`).then(response => response.text());
assert.strictEqual(snapshot.workItems.totalCount, 0); assert.ok(html.includes('ECC Control Pane'));
assert.strictEqual(snapshot.sessions[0].id, 'session-a'); assert.ok(html.includes('id="app"'));
assert.ok(html.includes('id="work-items"'));
assert.ok(html.includes('function renderWorkItems'));
assert.ok(html.includes('function showError'));
assert.ok(html.includes('response.ok'));
const snapshot = await fetchLocal(`${app.url}/api/snapshot?query=control`).then(response => response.json());
assert.strictEqual(snapshot.schemaVersion, 'ecc.control-pane.snapshot.v1');
assert.strictEqual(snapshot.summary.totalSessions, 1);
assert.strictEqual(snapshot.workItems.totalCount, 0);
assert.strictEqual(snapshot.sessions[0].id, 'session-a');
} finally {
await app.close();
}
} finally { } finally {
await app.close(); fs.rmSync(tempDir, { recursive: true, force: true });
} }
} finally { })
fs.rmSync(tempDir, { recursive: true, force: true }); )
} passed++;
})) passed++; else failed++; else failed++;
if (await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => { if (
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-')); await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));
try {
const app = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
dbPath: path.join(tempDir, 'missing.db'),
repoRoot: tempDir,
allowActions: false,
});
await app.listen();
try { try {
const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json()); const app = await createControlPaneServer({
assert.strictEqual(health.ok, true);
assert.strictEqual(health.allowActions, false);
const realAssetApp = await createControlPaneServer({
host: '127.0.0.1', host: '127.0.0.1',
port: 0, port: 0,
dbPath: path.join(tempDir, 'missing.db'), dbPath: path.join(tempDir, 'missing.db'),
repoRoot: REPO_ROOT, repoRoot: tempDir,
allowActions: false, allowActions: false
}); });
await realAssetApp.listen();
await app.listen();
try { try {
const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`); const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json());
assert.strictEqual(realAsset.status, 200); assert.strictEqual(health.ok, true);
assert.match(await realAsset.text(), /<svg/); assert.strictEqual(health.allowActions, false);
const realAssetApp = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
dbPath: path.join(tempDir, 'missing.db'),
repoRoot: REPO_ROOT,
allowActions: false
});
await realAssetApp.listen();
try {
const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`);
assert.strictEqual(realAsset.status, 200);
assert.match(await realAsset.text(), /<svg/);
} finally {
await realAssetApp.close();
}
const missingAsset = await fetchLocal(`${app.url}/assets/ecc-icon.svg`);
assert.strictEqual(missingAsset.status, 404);
assert.strictEqual(await missingAsset.text(), 'not found');
const missing = await fetchLocal(`${app.url}/not-here`).then(response => response.json());
assert.strictEqual(missing.ok, false);
assert.strictEqual(missing.error, 'not found');
const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: 'memory' })
}).then(async response => ({ status: response.status, body: await response.json() }));
assert.strictEqual(blocked.status, 403);
assert.match(blocked.body.error, /disabled/);
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{bad json'
}).then(async response => ({ status: response.status, body: await response.json() }));
assert.strictEqual(invalidBody.status, 403);
} finally { } finally {
await realAssetApp.close(); await app.close();
} }
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
const missingAsset = await fetchLocal(`${app.url}/assets/ecc-icon.svg`); if (
assert.strictEqual(missingAsset.status, 404); await test('guards copy-only and unknown action requests', async () => {
assert.strictEqual(await missingAsset.text(), 'not found'); const app = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
repoRoot: REPO_ROOT,
allowActions: true
});
const missing = await fetchLocal(`${app.url}/not-here`).then(response => response.json()); await app.listen();
assert.strictEqual(missing.ok, false); try {
assert.strictEqual(missing.error, 'not found'); const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, {
const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: 'memory' }), body: '{}'
}).then(async response => ({ status: response.status, body: await response.json() })); }).then(async response => ({ status: response.status, body: await response.json() }));
assert.strictEqual(blocked.status, 403); assert.strictEqual(copyOnly.status, 400);
assert.match(blocked.body.error, /disabled/); assert.strictEqual(copyOnly.body.action, 'open-dashboard');
assert.match(copyOnly.body.error, /copy-only/);
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}'
}).then(async response => ({ status: response.status, body: await response.json() }));
assert.strictEqual(unknown.status, 500);
assert.match(unknown.body.error, /Unknown control-pane action/);
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: '{bad json', body: '{bad json'
}).then(async response => ({ status: response.status, body: await response.json() })); }).then(async response => ({ status: response.status, body: await response.json() }));
assert.strictEqual(invalidBody.status, 403); assert.strictEqual(invalidBody.status, 500);
assert.match(invalidBody.body.error, /JSON/);
} finally { } finally {
await app.close(); await app.close();
} }
} finally { })
fs.rmSync(tempDir, { recursive: true, force: true }); )
} passed++;
})) passed++; else failed++; else failed++;
if (await test('guards copy-only and unknown action requests', async () => { if (
const app = await createControlPaneServer({ await test('classifies Host and Origin headers against the loopback allowlist', async () => {
host: '127.0.0.1', const allowed = buildAllowedHostnames('127.0.0.1');
port: 0, assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true);
repoRoot: REPO_ROOT, assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true);
allowActions: true, assert.strictEqual(isAllowedHostHeader('LOCALHOST:8765', allowed), true);
}); assert.strictEqual(isAllowedHostHeader('[::1]:8765', allowed), true);
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', allowed), false);
assert.strictEqual(isAllowedHostHeader('rebind.dnsbin.io', allowed), false);
assert.strictEqual(isAllowedHostHeader('', allowed), false);
assert.strictEqual(isAllowedHostHeader(undefined, allowed), false);
await app.listen(); // Origin is optional; absence is allowed for non-browser clients.
try { assert.strictEqual(isAllowedOrigin(undefined, allowed), true);
const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, { assert.strictEqual(isAllowedOrigin('', allowed), true);
method: 'POST', assert.strictEqual(isAllowedOrigin('http://127.0.0.1:8765', allowed), true);
headers: { 'content-type': 'application/json' }, assert.strictEqual(isAllowedOrigin('http://localhost', allowed), true);
body: '{}', assert.strictEqual(isAllowedOrigin('http://attacker.example.com', allowed), false);
}).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(isAllowedOrigin('not-a-url', allowed), false);
assert.strictEqual(copyOnly.status, 400);
assert.strictEqual(copyOnly.body.action, 'open-dashboard');
assert.match(copyOnly.body.error, /copy-only/);
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, { // A non-default configured host should still admit loopback variants.
method: 'POST', const lan = buildAllowedHostnames('192.168.1.10');
headers: { 'content-type': 'application/json' }, assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
body: '{}', assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
}).then(async response => ({ status: response.status, body: await response.json() })); assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
assert.strictEqual(unknown.status, 500); })
assert.match(unknown.body.error, /Unknown control-pane action/); )
passed++;
else failed++;
const invalidBody = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { if (
method: 'POST', await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
headers: { 'content-type': 'application/json' }, const app = await createControlPaneServer({
body: '{bad json', host: '127.0.0.1',
}).then(async response => ({ status: response.status, body: await response.json() })); port: 0,
assert.strictEqual(invalidBody.status, 500); repoRoot: REPO_ROOT,
assert.match(invalidBody.body.error, /JSON/); allowActions: true
} finally { });
await app.close();
}
})) passed++; else failed++;
if (await test('classifies Host and Origin headers against the loopback allowlist', async () => { await app.listen();
const allowed = buildAllowedHostnames('127.0.0.1'); try {
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true); const address = app.server.address();
assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true); const actualPort = address && typeof address === 'object' ? address.port : 0;
assert.strictEqual(isAllowedHostHeader('LOCALHOST:8765', allowed), true);
assert.strictEqual(isAllowedHostHeader('[::1]:8765', allowed), true);
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', allowed), false);
assert.strictEqual(isAllowedHostHeader('rebind.dnsbin.io', allowed), false);
assert.strictEqual(isAllowedHostHeader('', allowed), false);
assert.strictEqual(isAllowedHostHeader(undefined, allowed), false);
// Origin is optional; absence is allowed for non-browser clients. const sendWithHeaders = (method, pathname, headers, body) =>
assert.strictEqual(isAllowedOrigin(undefined, allowed), true); new Promise((resolve, reject) => {
assert.strictEqual(isAllowedOrigin('', allowed), true); const req = http.request({ host: '127.0.0.1', port: actualPort, method, path: pathname, headers }, response => {
assert.strictEqual(isAllowedOrigin('http://127.0.0.1:8765', allowed), true);
assert.strictEqual(isAllowedOrigin('http://localhost', allowed), true);
assert.strictEqual(isAllowedOrigin('http://attacker.example.com', allowed), false);
assert.strictEqual(isAllowedOrigin('not-a-url', allowed), false);
// A non-default configured host should still admit loopback variants.
const lan = buildAllowedHostnames('192.168.1.10');
assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
})) passed++; else failed++;
if (await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
const app = await createControlPaneServer({
host: '127.0.0.1',
port: 0,
repoRoot: REPO_ROOT,
allowActions: true,
});
await app.listen();
try {
const address = app.server.address();
const actualPort = address && typeof address === 'object' ? address.port : 0;
const sendWithHeaders = (method, pathname, headers, body) =>
new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port: actualPort, method, path: pathname, headers },
response => {
let chunks = ''; let chunks = '';
response.on('data', chunk => { response.on('data', chunk => {
chunks += chunk.toString('utf8'); chunks += chunk.toString('utf8');
@ -381,147 +387,233 @@ async function runTests() {
response.on('end', () => { response.on('end', () => {
resolve({ status: response.statusCode, body: chunks }); resolve({ status: response.statusCode, body: chunks });
}); });
} });
); req.on('error', reject);
req.on('error', reject); if (body) req.write(body);
if (body) req.write(body); req.end();
req.end(); });
const forgedHost = await sendWithHeaders('GET', '/api/health', { Host: 'attacker.example.com:1234' });
assert.strictEqual(forgedHost.status, 421);
assert.match(forgedHost.body, /Misdirected request/);
const forgedActionHost = await sendWithHeaders(
'POST',
'/api/actions/sync-knowledge',
{ Host: 'attacker.example.com:1234', 'content-type': 'application/json' },
JSON.stringify({ query: 'rebound' })
);
assert.strictEqual(forgedActionHost.status, 421);
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
Host: '127.0.0.1:' + actualPort,
Origin: 'http://attacker.example.com'
}); });
assert.strictEqual(forgedOrigin.status, 403);
assert.match(forgedOrigin.body, /Forbidden origin/);
const forgedHost = await sendWithHeaders('GET', '/api/health', { Host: 'attacker.example.com:1234' }); const okHost = await sendWithHeaders('GET', '/api/health', { Host: '127.0.0.1:' + actualPort });
assert.strictEqual(forgedHost.status, 421); assert.strictEqual(okHost.status, 200);
assert.match(forgedHost.body, /Misdirected request/); const okBody = JSON.parse(okHost.body);
assert.strictEqual(okBody.ok, true);
} finally {
await app.close();
}
})
)
passed++;
else failed++;
const forgedActionHost = await sendWithHeaders( if (
'POST', await test('runAction captures success, failure, and bounded output', async () => {
'/api/actions/sync-knowledge', const repoRoot = REPO_ROOT;
{ Host: 'attacker.example.com:1234', 'content-type': 'application/json' }, const success = await runAction({
JSON.stringify({ query: 'rebound' }) id: 'node-success',
);
assert.strictEqual(forgedActionHost.status, 421);
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
Host: '127.0.0.1:' + actualPort,
Origin: 'http://attacker.example.com',
});
assert.strictEqual(forgedOrigin.status, 403);
assert.match(forgedOrigin.body, /Forbidden origin/);
const okHost = await sendWithHeaders('GET', '/api/health', { Host: '127.0.0.1:' + actualPort });
assert.strictEqual(okHost.status, 200);
const okBody = JSON.parse(okHost.body);
assert.strictEqual(okBody.ok, true);
} finally {
await app.close();
}
})) passed++; else failed++;
if (await test('runAction captures success, failure, and bounded output', async () => {
const repoRoot = REPO_ROOT;
const success = await runAction({
id: 'node-success',
command: process.execPath,
args: ['-e', 'process.stdout.write("x".repeat(21010))'],
cwd: repoRoot,
});
assert.strictEqual(success.ok, true);
assert.strictEqual(success.code, 0);
assert.ok(success.stdout.includes('[truncated '));
const failure = await runAction({
id: 'node-failure',
command: process.execPath,
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
cwd: repoRoot,
});
assert.strictEqual(failure.ok, false);
assert.strictEqual(failure.code, 7);
assert.strictEqual(failure.stderr, 'bad');
const spawnError = await runAction({
id: 'spawn-error',
command: 'definitely-not-ecc-control-pane-command',
args: [],
cwd: repoRoot,
});
assert.strictEqual(spawnError.ok, false);
assert.strictEqual(spawnError.code, null);
assert.match(spawnError.error, /ENOENT/);
})) passed++; else failed++;
if (await test('runAction terminates commands that exceed the local timeout', async () => {
const timedOut = await runAction(
{
id: 'node-timeout',
command: process.execPath, command: process.execPath,
args: ['-e', 'setTimeout(() => {}, 5000)'], args: ['-e', 'process.stdout.write("x".repeat(21010))'],
cwd: REPO_ROOT, cwd: repoRoot
}, });
{ timeoutMs: 25 } assert.strictEqual(success.ok, true);
); assert.strictEqual(success.code, 0);
assert.ok(success.stdout.includes('[truncated '));
assert.strictEqual(timedOut.ok, false); const failure = await runAction({
assert.strictEqual(timedOut.signal, 'SIGTERM'); id: 'node-failure',
})) passed++; else failed++; command: process.execPath,
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
cwd: repoRoot
});
assert.strictEqual(failure.ok, false);
assert.strictEqual(failure.code, 7);
assert.strictEqual(failure.stderr, 'bad');
if (await test('CLI prints help', async () => { const spawnError = await runAction({
const result = spawnSync('node', [SCRIPT, '--help'], { id: 'spawn-error',
encoding: 'utf8', command: 'definitely-not-ecc-control-pane-command',
cwd: REPO_ROOT, args: [],
}); cwd: repoRoot
});
assert.strictEqual(spawnError.ok, false);
assert.strictEqual(spawnError.code, null);
assert.match(spawnError.error, /ENOENT/);
})
)
passed++;
else failed++;
assert.strictEqual(result.status, 0, result.stderr); if (
assert.ok(result.stdout.includes('Usage:')); await test('runAction terminates commands that exceed the local timeout', async () => {
assert.ok(result.stdout.includes('control-pane')); const timedOut = await runAction(
})) passed++; else failed++; {
id: 'node-timeout',
if (await test('CLI browser opener handles spawn errors', async () => { command: process.execPath,
const source = fs.readFileSync(SCRIPT, 'utf8'); args: ['-e', 'setTimeout(() => {}, 5000)'],
cwd: REPO_ROOT
assert.match(source, /child\.on\('error'/); },
assert.match(source, /child\.unref\(\)/); { timeoutMs: 25 }
})) passed++; else failed++;
if (await test('CLI main handles help without starting a server', async () => {
const originalLog = console.log;
const lines = [];
console.log = line => {
lines.push(String(line));
};
try {
await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']);
} finally {
console.log = originalLog;
}
assert.match(lines.join('\n'), /Usage:/);
assert.match(lines.join('\n'), /--read-only/);
})) passed++; else failed++;
if (await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => {
const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db'),
},
});
const exitPromise = waitForExit(child);
try {
const ready = await waitForCliReady(child);
assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/);
assert.match(ready.stdout, /Actions: read-only/);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
const result = await exitPromise;
assert.ok(
result.code === 0 || result.signal === 'SIGTERM',
`expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`
); );
}
})) passed++; else failed++; assert.strictEqual(timedOut.ok, false);
assert.strictEqual(timedOut.signal, 'SIGTERM');
})
)
passed++;
else failed++;
if (
await test('CLI prints help', async () => {
const result = spawnSync('node', [SCRIPT, '--help'], {
encoding: 'utf8',
cwd: REPO_ROOT
});
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(result.stdout.includes('Usage:'));
assert.ok(result.stdout.includes('control-pane'));
})
)
passed++;
else failed++;
if (
await test('CLI browser opener handles spawn errors', async () => {
const source = fs.readFileSync(SCRIPT, 'utf8');
assert.match(source, /child\.on\('error'/);
assert.match(source, /child\.unref\(\)/);
})
)
passed++;
else failed++;
if (
await test('CLI main handles help without starting a server', async () => {
const originalLog = console.log;
const lines = [];
console.log = line => {
lines.push(String(line));
};
try {
await runControlPaneCli(['node', 'scripts/control-pane.js', '--help']);
} finally {
console.log = originalLog;
}
assert.match(lines.join('\n'), /Usage:/);
assert.match(lines.join('\n'), /--read-only/);
})
)
passed++;
else failed++;
if (
await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => {
const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], {
cwd: REPO_ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db')
}
});
const exitPromise = waitForExit(child);
try {
const ready = await waitForCliReady(child);
assert.match(ready.stdout, /ECC Control Pane: http:\/\/127\.0\.0\.1:\d+/);
assert.match(ready.stdout, /Actions: read-only/);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
const result = await exitPromise;
assert.ok(result.code === 0 || result.signal === 'SIGTERM', `expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`);
}
})
)
passed++;
else failed++;
if (
await test('interactive board: claim and move work items via POST endpoints', async () => {
const { createStateStore } = require('../../scripts/lib/state-store');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-board-'));
const stateDbPath = path.join(tempDir, 'state.db');
try {
const store = await createStateStore({ dbPath: stateDbPath });
store.upsertWorkItem({ id: 'wi-1', source: 'github-issue', title: 'Unassigned card', status: 'open', priority: 'high', owner: null, metadata: {} });
store.upsertWorkItem({ id: 'wi-2', source: 'manual', title: 'Movable card', status: 'open', owner: 'codex', metadata: {} });
store.close();
const post = (app, suffix, body) =>
fetchLocal(`${app.url}/api/work-items/${suffix}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
// Read-only server rejects board edits.
const ro = await createControlPaneServer({ host: '127.0.0.1', port: 0, stateDbPath, repoRoot: REPO_ROOT, allowActions: false });
await ro.listen();
try {
const denied = await post(ro, 'wi-1/claim', { owner: 'alice' });
assert.strictEqual(denied.status, 403);
} finally {
await ro.close();
}
// Interactive server claims and moves.
const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, stateDbPath, repoRoot: REPO_ROOT, allowActions: true });
await app.listen();
try {
const claim = await post(app, 'wi-1/claim', { owner: 'alice', as: 'human' }).then(r => r.json());
assert.strictEqual(claim.ok, true);
assert.strictEqual(claim.item.owner, 'alice');
assert.strictEqual(claim.item.status, 'running');
assert.strictEqual(claim.item.metadata.assigneeKind, 'human');
const move = await post(app, 'wi-2/move', { lane: 'blocked' }).then(r => r.json());
assert.strictEqual(move.ok, true);
assert.strictEqual(move.item.status, 'blocked');
// Snapshot reflects the mutations.
const snapshot = await fetchLocal(`${app.url}/api/snapshot`).then(r => r.json());
const byId = id => snapshot.workItems.items.find(i => i.id === id);
assert.strictEqual(byId('wi-1').assigneeKind, 'human');
assert.strictEqual(byId('wi-2').kanbanState, 'blocked');
// Invalid lane is a 400.
const bad = await post(app, 'wi-2/move', { lane: 'nope' });
assert.strictEqual(bad.status, 400);
} finally {
await app.close();
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);