mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-19 19:30:29 +08:00
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:
parent
7fd4ba95ae
commit
607ab02b1f
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
121
scripts/lib/control-pane/work-item-mutations.js
Normal file
121
scripts/lib/control-pane/work-item-mutations.js
Normal 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
|
||||||
|
};
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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,7 +133,8 @@ 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 (
|
||||||
|
await test('parses CLI arguments for local-only serving', async () => {
|
||||||
const parsed = parseArgs([
|
const parsed = parseArgs([
|
||||||
'node',
|
'node',
|
||||||
'scripts/control-pane.js',
|
'scripts/control-pane.js',
|
||||||
@ -156,7 +148,7 @@ async function runTests() {
|
|||||||
'/tmp/ecc-state.db',
|
'/tmp/ecc-state.db',
|
||||||
'--query',
|
'--query',
|
||||||
'Hermes memory',
|
'Hermes memory',
|
||||||
'--no-open',
|
'--no-open'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.strictEqual(parsed.host, '127.0.0.1');
|
assert.strictEqual(parsed.host, '127.0.0.1');
|
||||||
@ -165,31 +157,31 @@ async function runTests() {
|
|||||||
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 (
|
||||||
|
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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-server-'));
|
||||||
const dbPath = path.join(tempDir, 'ecc2.db');
|
const dbPath = path.join(tempDir, 'ecc2.db');
|
||||||
|
|
||||||
@ -201,7 +193,7 @@ async function runTests() {
|
|||||||
dbPath,
|
dbPath,
|
||||||
repoRoot: REPO_ROOT,
|
repoRoot: REPO_ROOT,
|
||||||
query: 'control pane',
|
query: 'control pane',
|
||||||
allowActions: false,
|
allowActions: false
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen();
|
await app.listen();
|
||||||
@ -225,9 +217,13 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
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 () => {
|
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-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -236,7 +232,7 @@ async function runTests() {
|
|||||||
port: 0,
|
port: 0,
|
||||||
dbPath: path.join(tempDir, 'missing.db'),
|
dbPath: path.join(tempDir, 'missing.db'),
|
||||||
repoRoot: tempDir,
|
repoRoot: tempDir,
|
||||||
allowActions: false,
|
allowActions: false
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen();
|
await app.listen();
|
||||||
@ -250,7 +246,7 @@ async function runTests() {
|
|||||||
port: 0,
|
port: 0,
|
||||||
dbPath: path.join(tempDir, 'missing.db'),
|
dbPath: path.join(tempDir, 'missing.db'),
|
||||||
repoRoot: REPO_ROOT,
|
repoRoot: REPO_ROOT,
|
||||||
allowActions: false,
|
allowActions: false
|
||||||
});
|
});
|
||||||
await realAssetApp.listen();
|
await realAssetApp.listen();
|
||||||
try {
|
try {
|
||||||
@ -272,7 +268,7 @@ async function runTests() {
|
|||||||
const blocked = await fetchLocal(`${app.url}/api/actions/sync-knowledge`, {
|
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: JSON.stringify({ query: 'memory' })
|
||||||
}).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(blocked.status, 403);
|
||||||
assert.match(blocked.body.error, /disabled/);
|
assert.match(blocked.body.error, /disabled/);
|
||||||
@ -280,7 +276,7 @@ async function runTests() {
|
|||||||
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, 403);
|
||||||
} finally {
|
} finally {
|
||||||
@ -289,14 +285,18 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('guards copy-only and unknown action requests', async () => {
|
if (
|
||||||
|
await test('guards copy-only and unknown action requests', async () => {
|
||||||
const app = await createControlPaneServer({
|
const app = await createControlPaneServer({
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 0,
|
port: 0,
|
||||||
repoRoot: REPO_ROOT,
|
repoRoot: REPO_ROOT,
|
||||||
allowActions: true,
|
allowActions: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen();
|
await app.listen();
|
||||||
@ -304,7 +304,7 @@ async function runTests() {
|
|||||||
const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, {
|
const copyOnly = await fetchLocal(`${app.url}/api/actions/open-dashboard`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: '{}',
|
body: '{}'
|
||||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||||
assert.strictEqual(copyOnly.status, 400);
|
assert.strictEqual(copyOnly.status, 400);
|
||||||
assert.strictEqual(copyOnly.body.action, 'open-dashboard');
|
assert.strictEqual(copyOnly.body.action, 'open-dashboard');
|
||||||
@ -313,7 +313,7 @@ async function runTests() {
|
|||||||
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, {
|
const unknown = await fetchLocal(`${app.url}/api/actions/nope`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: '{}',
|
body: '{}'
|
||||||
}).then(async response => ({ status: response.status, body: await response.json() }));
|
}).then(async response => ({ status: response.status, body: await response.json() }));
|
||||||
assert.strictEqual(unknown.status, 500);
|
assert.strictEqual(unknown.status, 500);
|
||||||
assert.match(unknown.body.error, /Unknown control-pane action/);
|
assert.match(unknown.body.error, /Unknown control-pane action/);
|
||||||
@ -321,16 +321,20 @@ async function runTests() {
|
|||||||
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, 500);
|
assert.strictEqual(invalidBody.status, 500);
|
||||||
assert.match(invalidBody.body.error, /JSON/);
|
assert.match(invalidBody.body.error, /JSON/);
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('classifies Host and Origin headers against the loopback allowlist', async () => {
|
if (
|
||||||
|
await test('classifies Host and Origin headers against the loopback allowlist', async () => {
|
||||||
const allowed = buildAllowedHostnames('127.0.0.1');
|
const allowed = buildAllowedHostnames('127.0.0.1');
|
||||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true);
|
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', allowed), true);
|
||||||
assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true);
|
assert.strictEqual(isAllowedHostHeader('localhost:8765', allowed), true);
|
||||||
@ -354,14 +358,18 @@ async function runTests() {
|
|||||||
assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
|
assert.strictEqual(isAllowedHostHeader('192.168.1.10:8765', lan), true);
|
||||||
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
|
assert.strictEqual(isAllowedHostHeader('127.0.0.1:8765', lan), true);
|
||||||
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
|
assert.strictEqual(isAllowedHostHeader('attacker.example.com:8765', lan), false);
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
|
if (
|
||||||
|
await test('rejects requests forged with a non-loopback Host header (DNS rebinding gate)', async () => {
|
||||||
const app = await createControlPaneServer({
|
const app = await createControlPaneServer({
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 0,
|
port: 0,
|
||||||
repoRoot: REPO_ROOT,
|
repoRoot: REPO_ROOT,
|
||||||
allowActions: true,
|
allowActions: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen();
|
await app.listen();
|
||||||
@ -371,9 +379,7 @@ async function runTests() {
|
|||||||
|
|
||||||
const sendWithHeaders = (method, pathname, headers, body) =>
|
const sendWithHeaders = (method, pathname, headers, body) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const req = http.request(
|
const req = http.request({ host: '127.0.0.1', port: actualPort, method, path: pathname, headers }, response => {
|
||||||
{ 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,8 +387,7 @@ 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();
|
||||||
@ -402,7 +407,7 @@ async function runTests() {
|
|||||||
|
|
||||||
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
|
const forgedOrigin = await sendWithHeaders('GET', '/api/health', {
|
||||||
Host: '127.0.0.1:' + actualPort,
|
Host: '127.0.0.1:' + actualPort,
|
||||||
Origin: 'http://attacker.example.com',
|
Origin: 'http://attacker.example.com'
|
||||||
});
|
});
|
||||||
assert.strictEqual(forgedOrigin.status, 403);
|
assert.strictEqual(forgedOrigin.status, 403);
|
||||||
assert.match(forgedOrigin.body, /Forbidden origin/);
|
assert.match(forgedOrigin.body, /Forbidden origin/);
|
||||||
@ -414,15 +419,19 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('runAction captures success, failure, and bounded output', async () => {
|
if (
|
||||||
|
await test('runAction captures success, failure, and bounded output', async () => {
|
||||||
const repoRoot = REPO_ROOT;
|
const repoRoot = REPO_ROOT;
|
||||||
const success = await runAction({
|
const success = await runAction({
|
||||||
id: 'node-success',
|
id: 'node-success',
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ['-e', 'process.stdout.write("x".repeat(21010))'],
|
args: ['-e', 'process.stdout.write("x".repeat(21010))'],
|
||||||
cwd: repoRoot,
|
cwd: repoRoot
|
||||||
});
|
});
|
||||||
assert.strictEqual(success.ok, true);
|
assert.strictEqual(success.ok, true);
|
||||||
assert.strictEqual(success.code, 0);
|
assert.strictEqual(success.code, 0);
|
||||||
@ -432,7 +441,7 @@ async function runTests() {
|
|||||||
id: 'node-failure',
|
id: 'node-failure',
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
|
args: ['-e', 'process.stderr.write("bad"); process.exit(7)'],
|
||||||
cwd: repoRoot,
|
cwd: repoRoot
|
||||||
});
|
});
|
||||||
assert.strictEqual(failure.ok, false);
|
assert.strictEqual(failure.ok, false);
|
||||||
assert.strictEqual(failure.code, 7);
|
assert.strictEqual(failure.code, 7);
|
||||||
@ -442,47 +451,63 @@ async function runTests() {
|
|||||||
id: 'spawn-error',
|
id: 'spawn-error',
|
||||||
command: 'definitely-not-ecc-control-pane-command',
|
command: 'definitely-not-ecc-control-pane-command',
|
||||||
args: [],
|
args: [],
|
||||||
cwd: repoRoot,
|
cwd: repoRoot
|
||||||
});
|
});
|
||||||
assert.strictEqual(spawnError.ok, false);
|
assert.strictEqual(spawnError.ok, false);
|
||||||
assert.strictEqual(spawnError.code, null);
|
assert.strictEqual(spawnError.code, null);
|
||||||
assert.match(spawnError.error, /ENOENT/);
|
assert.match(spawnError.error, /ENOENT/);
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('runAction terminates commands that exceed the local timeout', async () => {
|
if (
|
||||||
|
await test('runAction terminates commands that exceed the local timeout', async () => {
|
||||||
const timedOut = await runAction(
|
const timedOut = await runAction(
|
||||||
{
|
{
|
||||||
id: 'node-timeout',
|
id: 'node-timeout',
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
args: ['-e', 'setTimeout(() => {}, 5000)'],
|
args: ['-e', 'setTimeout(() => {}, 5000)'],
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT
|
||||||
},
|
},
|
||||||
{ timeoutMs: 25 }
|
{ timeoutMs: 25 }
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(timedOut.ok, false);
|
assert.strictEqual(timedOut.ok, false);
|
||||||
assert.strictEqual(timedOut.signal, 'SIGTERM');
|
assert.strictEqual(timedOut.signal, 'SIGTERM');
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('CLI prints help', async () => {
|
if (
|
||||||
|
await test('CLI prints help', async () => {
|
||||||
const result = spawnSync('node', [SCRIPT, '--help'], {
|
const result = spawnSync('node', [SCRIPT, '--help'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.status, 0, result.stderr);
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
assert.ok(result.stdout.includes('Usage:'));
|
assert.ok(result.stdout.includes('Usage:'));
|
||||||
assert.ok(result.stdout.includes('control-pane'));
|
assert.ok(result.stdout.includes('control-pane'));
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('CLI browser opener handles spawn errors', async () => {
|
if (
|
||||||
|
await test('CLI browser opener handles spawn errors', async () => {
|
||||||
const source = fs.readFileSync(SCRIPT, 'utf8');
|
const source = fs.readFileSync(SCRIPT, 'utf8');
|
||||||
|
|
||||||
assert.match(source, /child\.on\('error'/);
|
assert.match(source, /child\.on\('error'/);
|
||||||
assert.match(source, /child\.unref\(\)/);
|
assert.match(source, /child\.unref\(\)/);
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('CLI main handles help without starting a server', async () => {
|
if (
|
||||||
|
await test('CLI main handles help without starting a server', async () => {
|
||||||
const originalLog = console.log;
|
const originalLog = console.log;
|
||||||
const lines = [];
|
const lines = [];
|
||||||
console.log = line => {
|
console.log = line => {
|
||||||
@ -496,16 +521,20 @@ async function runTests() {
|
|||||||
|
|
||||||
assert.match(lines.join('\n'), /Usage:/);
|
assert.match(lines.join('\n'), /Usage:/);
|
||||||
assert.match(lines.join('\n'), /--read-only/);
|
assert.match(lines.join('\n'), /--read-only/);
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (await test('CLI starts a read-only local server and shuts down on SIGTERM', async () => {
|
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'], {
|
const child = spawn(process.execPath, [SCRIPT, '--host', '127.0.0.1', '--port', '0', '--read-only', '--no-open'], {
|
||||||
cwd: REPO_ROOT,
|
cwd: REPO_ROOT,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db'),
|
ECC2_DB_PATH: path.join(os.tmpdir(), 'missing-ecc2-cli.db')
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
const exitPromise = waitForExit(child);
|
const exitPromise = waitForExit(child);
|
||||||
|
|
||||||
@ -516,12 +545,75 @@ async function runTests() {
|
|||||||
} finally {
|
} finally {
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
|
||||||
const result = await exitPromise;
|
const result = await exitPromise;
|
||||||
assert.ok(
|
assert.ok(result.code === 0 || result.signal === 'SIGTERM', `expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`);
|
||||||
result.code === 0 || result.signal === 'SIGTERM',
|
|
||||||
`expected graceful shutdown or SIGTERM, got code=${result.code} signal=${result.signal}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
})) passed++; else failed++;
|
})
|
||||||
|
)
|
||||||
|
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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user