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
+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>