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

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

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

Full suite 2845/2845; lint green.
This commit is contained in:
Affaan Mustafa
2026-06-18 18:16:46 -04:00
parent 7fd4ba95ae
commit 607ab02b1f
5 changed files with 671 additions and 417 deletions
+62 -17
View File
@@ -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 <path> 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
};
+46 -1
View File
@@ -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 '<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">' +
'<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>' +
(branch ? '<div class="subtle">branch: ' + escapeHtml(branch) + '</div>' : '') +
(mergeGate ? '<div class="subtle">merge gate: ' + escapeHtml(mergeGate) + '</div>' : '') +
(blocker ? '<div class="subtle">blocker: ' + escapeHtml(blocker) + '</div>' : '') +
controls +
'</div>';
}).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));
</script>
</body>
@@ -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
};