diff --git a/scripts/lib/control-pane/server.js b/scripts/lib/control-pane/server.js index 6e42533e..b7b27cd2 100644 --- a/scripts/lib/control-pane/server.js +++ b/scripts/lib/control-pane/server.js @@ -8,6 +8,20 @@ const { spawn } = require('child_process'); const { buildControlPaneAction } = require('./actions'); const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state'); 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']); @@ -55,7 +69,7 @@ function usage() { ' --state-db Read agent work items from an ECC state-store database', ' --read-only Disable action execution endpoints', ' --no-open Do not open a browser after the server starts', - ' --help Show this help', + ' --help Show this help' ].join('\n'); } @@ -92,7 +106,7 @@ function parseArgs(argv) { configPath: valueAfter(args, '--config'), query: valueAfter(args, '--query') || '', 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); res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', + 'cache-control': 'no-store' }); res.end(`${body}\n`); } @@ -108,7 +122,7 @@ function sendJson(res, statusCode, payload) { function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') { res.writeHead(statusCode, { 'content-type': contentType, - 'cache-control': 'no-store', + 'cache-control': 'no-store' }); res.end(body); } @@ -135,7 +149,7 @@ function runAction(action, options = {}) { const child = spawn(action.command, action.args, { cwd: action.cwd, env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; @@ -163,7 +177,7 @@ function runAction(action, options = {}) { code: null, error: error.message, stdout: boundedOutput(stdout), - stderr: boundedOutput(stderr), + stderr: boundedOutput(stderr) }); }); child.on('close', (code, signal) => { @@ -177,7 +191,7 @@ function runAction(action, options = {}) { code, signal, stdout: boundedOutput(stdout), - stderr: boundedOutput(stderr), + stderr: boundedOutput(stderr) }); }); }); @@ -193,7 +207,7 @@ function createControlPaneServer(options = {}) { configPath: options.configPath, dbPath: options.dbPath, stateDbPath: options.stateDbPath, - env: options.env || process.env, + env: options.env || process.env }); const baseQuery = options.query || ''; const allowedHostnames = buildAllowedHostnames(host); @@ -232,7 +246,7 @@ function createControlPaneServer(options = {}) { repoRoot, dbPath: resolvedConfig.dbPath, stateDbPath: resolvedConfig.stateDbPath, - allowActions, + allowActions }); return; } @@ -245,7 +259,7 @@ function createControlPaneServer(options = {}) { config: resolvedConfig, query: requestUrl.searchParams.get('query') || baseQuery, limit: requestUrl.searchParams.get('limit') || 12, - allowActions, + allowActions }); sendJson(res, 200, snapshot); return; @@ -256,7 +270,7 @@ function createControlPaneServer(options = {}) { if (!allowActions) { sendJson(res, 403, { ok: false, - error: 'Control-pane action execution is disabled by --read-only.', + error: 'Control-pane action execution is disabled by --read-only.' }); return; } @@ -265,7 +279,7 @@ function createControlPaneServer(options = {}) { const action = buildControlPaneAction(decodeURIComponent(actionMatch[1]), { repoRoot, query: body.query || baseQuery, - limit: body.limit || 25, + limit: body.limit || 25 }); if (!action.executable) { @@ -273,7 +287,7 @@ function createControlPaneServer(options = {}) { ok: false, action: action.id, error: 'This action is copy-only and cannot be executed from the browser.', - commandLine: action.commandLine, + commandLine: action.commandLine }); return; } @@ -281,16 +295,47 @@ function createControlPaneServer(options = {}) { const result = await runAction(action); sendJson(res, result.ok ? 200 : 500, { ...result, - commandLine: action.commandLine, + commandLine: action.commandLine }); 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' }); } catch (error) { sendJson(res, 500, { ok: false, - error: error.message, + error: error.message }); } }); @@ -319,7 +364,7 @@ function createControlPaneServer(options = {}) { else resolve(); }); }); - }, + } }; } @@ -330,5 +375,5 @@ module.exports = { isAllowedHostHeader, isAllowedOrigin, buildAllowedHostnames, - usage, + usage }; diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js index 337416ad..1bf0a7a7 100644 --- a/scripts/lib/control-pane/ui.js +++ b/scripts/lib/control-pane/ui.js @@ -519,12 +519,24 @@ function renderControlPaneHtml() { const blocker = item.blocker || (item.metadata && item.metadata.blocker) || ''; const assigneeKind = item.assigneeKind || '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 ''; + }).join(''); + const controls = state.allowActions + ? '
' + + (assigneeKind === 'unassigned' ? '' : '') + + moveButtons + + '
' + : ''; return '
' + '
' + escapeHtml(item.title || item.id) + '' + statePill(item.kanbanState || item.status) + '
' + '
[' + escapeHtml(assigneeKind) + '] ' + escapeHtml(owner) + ' - ' + escapeHtml(item.source || 'manual') + (item.priority ? ' - ' + escapeHtml(item.priority) : '') + '
' + (branch ? '
branch: ' + escapeHtml(branch) + '
' : '') + (mergeGate ? '
merge gate: ' + escapeHtml(mergeGate) + '
' : '') + (blocker ? '
blocker: ' + escapeHtml(blocker) + '
' : '') + + controls + '
'; }).join(''); } @@ -606,7 +618,8 @@ function renderControlPaneHtml() { const snapshot = await readJsonResponse(response); $('#query').value = snapshot.knowledge.query || state.query; $('#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); renderSessions(snapshot.sessions); renderWorkItems(snapshot.workItems); @@ -627,6 +640,38 @@ function renderControlPaneHtml() { $('#refresh').addEventListener('click', () => { 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)); diff --git a/scripts/lib/control-pane/work-item-mutations.js b/scripts/lib/control-pane/work-item-mutations.js new file mode 100644 index 00000000..c82a2619 --- /dev/null +++ b/scripts/lib/control-pane/work-item-mutations.js @@ -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 +}; diff --git a/scripts/work-items.js b/scripts/work-items.js index 2afff589..6c9d01aa 100644 --- a/scripts/work-items.js +++ b/scripts/work-items.js @@ -4,6 +4,7 @@ const os = require('os'); const { spawnSync } = require('child_process'); const { createStateStore } = require('./lib/state-store'); +const { claimWorkItem } = require('./lib/control-pane/work-item-mutations'); const VALUE_FLAGS = new Set([ '--as', @@ -388,73 +389,23 @@ function printGithubSyncResult(payload) { } } -const CLAIM_DONE_STATUSES = new Set(['done', 'closed', 'resolved', 'merged', 'cancelled']); -const CLAIM_PRIORITY_RANK = { critical: 0, high: 1, urgent: 1, medium: 2, normal: 2, low: 3 }; - -function isOpenWorkItemStatus(status) { - 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) { +// Thin CLI adapter over the shared claimWorkItem helper, preserving the +// flag-specific error messages (--owner / --as) for the command line. +function claimWorkItemCli(store, options) { + if (!options.owner) { throw new Error('claim requires --owner .'); } const assigneeKind = options.claimAs ? String(options.claimAs).toLowerCase() : null; if (assigneeKind && assigneeKind !== 'agent' && assigneeKind !== 'human') { throw new Error("--as must be 'agent' or 'human'."); } - const target = selectClaimTarget(store, options); - if (!target) { - return { claimed: false, reason: 'no-unassigned-open-items' }; - } - if (!isOpenWorkItemStatus(target.status)) { - throw new Error(`Work item ${target.id} is already done; cannot claim.`); - } - 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 claimWorkItem(store, { + id: resolveWorkItemId(options), + owner: options.owner, + assigneeKind, + sessionId: options.sessionId, + status: options.status }); - return { claimed: true, item }; } async function main() { @@ -538,7 +489,7 @@ async function main() { } if (options.command === 'claim') { - const result = claimWorkItem(store, options); + const result = claimWorkItemCli(store, options); if (options.json) { console.log(JSON.stringify(result, null, 2)); } else if (!result.claimed) { diff --git a/tests/scripts/control-pane.test.js b/tests/scripts/control-pane.test.js index 1a9d74ef..d209e367 100644 --- a/tests/scripts/control-pane.test.js +++ b/tests/scripts/control-pane.test.js @@ -11,17 +11,8 @@ const { spawn, spawnSync } = require('child_process'); const initSqlJs = require('sql.js'); -const { - createControlPaneServer, - parseArgs, - runAction, - isAllowedHostHeader, - isAllowedOrigin, - buildAllowedHostnames, -} = require('../../scripts/lib/control-pane/server'); -const { - main: runControlPaneCli, -} = require('../../scripts/control-pane'); +const { createControlPaneServer, 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 REPO_ROOT = path.join(__dirname, '..', '..'); @@ -142,238 +133,253 @@ async function runTests() { let passed = 0; let failed = 0; - if (await test('parses CLI arguments for local-only serving', async () => { - const parsed = parseArgs([ - 'node', - 'scripts/control-pane.js', - '--host', - '127.0.0.1', - '--port', - '8788', - '--db', - '/tmp/ecc2.db', - '--state-db', - '/tmp/ecc-state.db', - '--query', - 'Hermes memory', - '--no-open', - ]); + if ( + await test('parses CLI arguments for local-only serving', async () => { + const parsed = parseArgs([ + 'node', + 'scripts/control-pane.js', + '--host', + '127.0.0.1', + '--port', + '8788', + '--db', + '/tmp/ecc2.db', + '--state-db', + '/tmp/ecc-state.db', + '--query', + 'Hermes memory', + '--no-open' + ]); - assert.strictEqual(parsed.host, '127.0.0.1'); - assert.strictEqual(parsed.port, 8788); - assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db'); - assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db'); - assert.strictEqual(parsed.query, 'Hermes memory'); - assert.strictEqual(parsed.openBrowser, false); - })) passed++; else failed++; + assert.strictEqual(parsed.host, '127.0.0.1'); + assert.strictEqual(parsed.port, 8788); + assert.strictEqual(parsed.dbPath, '/tmp/ecc2.db'); + assert.strictEqual(parsed.stateDbPath, '/tmp/ecc-state.db'); + assert.strictEqual(parsed.query, 'Hermes memory'); + assert.strictEqual(parsed.openBrowser, false); + }) + ) + passed++; + else failed++; - if (await test('rejects invalid CLI port values', async () => { - assert.throws( - () => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), - /Invalid --port value/ - ); - assert.throws( - () => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), - /Invalid --port value/ - ); - })) passed++; else failed++; + if ( + await test('rejects invalid CLI port values', async () => { + assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', '70000']), /Invalid --port value/); + assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--port', 'wat']), /Invalid --port value/); + }) + ) + passed++; + else failed++; - if (await test('rejects missing state database path values', async () => { - assert.throws( - () => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), - /Invalid --state-db value/ - ); - assert.throws( - () => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), - /Invalid --state-db value/ - ); - })) passed++; else failed++; + if ( + await test('rejects missing state database path values', async () => { + assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db']), /Invalid --state-db value/); + assert.throws(() => parseArgs(['node', 'scripts/control-pane.js', '--state-db', '--query', 'Hermes']), /Invalid --state-db value/); + }) + ) + passed++; + else failed++; - if (await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-')); - const dbPath = path.join(tempDir, 'ecc2.db'); + if ( + await test('serves HTML and snapshot JSON from a temp ECC2 database', async () => { + 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 { - const html = await fetchLocal(`${app.url}/`).then(response => response.text()); - assert.ok(html.includes('ECC Control Pane')); - 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')); + await writeMinimalDatabase(dbPath); + const app = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + dbPath, + repoRoot: REPO_ROOT, + query: 'control pane', + allowActions: false + }); - 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'); + await app.listen(); + try { + const html = await fetchLocal(`${app.url}/`).then(response => response.text()); + assert.ok(html.includes('ECC Control Pane')); + 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 { - await app.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - })) passed++; else failed++; + }) + ) + passed++; + else failed++; - if (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-')); + if ( + 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 { - const health = await fetchLocal(`${app.url}/api/health`).then(response => response.json()); - assert.strictEqual(health.ok, true); - assert.strictEqual(health.allowActions, false); - - const realAssetApp = await createControlPaneServer({ + const app = await createControlPaneServer({ host: '127.0.0.1', port: 0, dbPath: path.join(tempDir, 'missing.db'), - repoRoot: REPO_ROOT, - allowActions: false, + repoRoot: tempDir, + allowActions: false }); - await realAssetApp.listen(); + + await app.listen(); try { - const realAsset = await fetchLocal(`${realAssetApp.url}/assets/ecc-icon.svg`); - assert.strictEqual(realAsset.status, 200); - assert.match(await realAsset.text(), / response.json()); + assert.strictEqual(health.ok, true); + 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(), / 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 { - 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`); - assert.strictEqual(missingAsset.status, 404); - assert.strictEqual(await missingAsset.text(), 'not found'); + if ( + await test('guards copy-only and unknown action requests', async () => { + 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()); - assert.strictEqual(missing.ok, false); - assert.strictEqual(missing.error, 'not found'); - - const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, { + await app.listen(); + try { + const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ query: 'memory' }), + body: '{}' }).then(async response => ({ status: response.status, body: await response.json() })); - assert.strictEqual(blocked.status, 403); - assert.match(blocked.body.error, /disabled/); + 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`, { + 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`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: '{bad json', + body: '{bad 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 { await app.close(); } - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } - })) passed++; else failed++; + }) + ) + passed++; + else failed++; - if (await test('guards copy-only and unknown action requests', async () => { - const app = await createControlPaneServer({ - host: '127.0.0.1', - port: 0, - repoRoot: REPO_ROOT, - allowActions: true, - }); + if ( + await test('classifies Host and Origin headers against the loopback allowlist', async () => { + const allowed = buildAllowedHostnames('127.0.0.1'); + assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true); + assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), 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(); - try { - const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', - }).then(async response => ({ status: response.status, body: await response.json() })); - assert.strictEqual(copyOnly.status, 400); - assert.strictEqual(copyOnly.body.action, 'open-dashboard'); - assert.match(copyOnly.body.error, /copy-only/); + // Origin is optional; absence is allowed for non-browser clients. + assert.strictEqual(isAllowedOrigin(undefined, allowed), true); + assert.strictEqual(isAllowedOrigin('', allowed), true); + 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); - 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/); + // 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++; - 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, 500); - assert.match(invalidBody.body.error, /JSON/); - } finally { - await app.close(); - } - })) 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 + }); - if (await test('classifies Host and Origin headers against the loopback allowlist', async () => { - const allowed = buildAllowedHostnames('127.0.0.1'); - assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true); - assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), 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(); + try { + const address = app.server.address(); + const actualPort = address && typeof address === 'object' ? address.port : 0; - // Origin is optional; absence is allowed for non-browser clients. - assert.strictEqual(isAllowedOrigin(undefined, allowed), true); - assert.strictEqual(isAllowedOrigin('', allowed), true); - 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 => { + 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 = ''; response.on('data', chunk => { chunks += chunk.toString('utf8'); @@ -381,147 +387,233 @@ async function runTests() { response.on('end', () => { resolve({ status: response.statusCode, body: chunks }); }); - } - ); - req.on('error', reject); - if (body) req.write(body); - req.end(); + }); + req.on('error', reject); + if (body) req.write(body); + 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' }); - assert.strictEqual(forgedHost.status, 421); - assert.match(forgedHost.body, /Misdirected request/); + 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++; - 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 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', + 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', 'setTimeout(() => {}, 5000)'], - cwd: REPO_ROOT, - }, - { timeoutMs: 25 } - ); + 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 ')); - assert.strictEqual(timedOut.ok, false); - assert.strictEqual(timedOut.signal, 'SIGTERM'); - })) passed++; else failed++; + 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'); - if (await test('CLI prints help', async () => { - const result = spawnSync('node', [SCRIPT, '--help'], { - encoding: 'utf8', - cwd: REPO_ROOT, - }); + 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++; - 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}` + if ( + await test('runAction terminates commands that exceed the local timeout', async () => { + const timedOut = await runAction( + { + id: 'node-timeout', + command: process.execPath, + args: ['-e', 'setTimeout(() => {}, 5000)'], + cwd: REPO_ROOT + }, + { timeoutMs: 25 } ); - } - })) 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}`); process.exit(failed > 0 ? 1 : 0);