mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-26 10:01:28 +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:
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user