input-kanban 0.0.14 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +18 -0
- package/package.json +1 -1
- package/public/index.html +17 -13
- package/src/orchestrator.js +34 -3
- package/src/server.js +10 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.0.15
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Absorb PR #3 recovery hardening without directly merging its older `v0.0.13` branch, preserving the `v0.0.14` Plan Approval Gate and Web layout changes.
|
|
8
|
+
- Route blocked-run dashboard execution actions to `/api/runs/:id/retry`, so `batch_blocked` runs retry failed/unknown tasks instead of hitting the dispatch endpoint.
|
|
9
|
+
- Remove stale Web `workers_completed` / `workers_failed` UI state handling now that backend run status uses `batches_completed` and `batch_blocked`.
|
|
10
|
+
- Harden final judge starts: reject archived/stopped runs, duplicate running judges, and completed judges; failed judges are archived to `judge_attempts/` before retrying.
|
|
11
|
+
- Add short `/api/codex` detection caching to avoid repeatedly spawning Codex detection during frequent dashboard refreshes.
|
|
12
|
+
- Keep task-table Codex session IDs and their copy buttons on one line by widening the session column and using a compact inline layout.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- `npm run check` passed locally with 84 tests.
|
|
17
|
+
- `npm pack --dry-run` passed for `input-kanban@0.0.15`.
|
|
18
|
+
- Windows release-candidate validation on `zhangxing_win` passed with 84 tests.
|
|
19
|
+
- Windows Web smoke confirmed `/api/health`, `/api/codex`, Plan Approval UI, compact header copy tools, and one-line Codex session copy layout.
|
|
20
|
+
|
|
3
21
|
## v0.0.14
|
|
4
22
|
|
|
5
23
|
### Highlights
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -41,20 +41,20 @@
|
|
|
41
41
|
@media (prefers-reduced-motion: reduce) { button, button.state-active, button.state-pending::after, .refresh-pulse-chip.pulse .refresh-pulse-dot { animation: none !important; transition: none !important; } }
|
|
42
42
|
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
43
43
|
th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
|
|
44
|
-
th:nth-child(1), td:nth-child(1) { width:
|
|
44
|
+
th:nth-child(1), td:nth-child(1) { width: 32%; }
|
|
45
45
|
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
46
46
|
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
47
47
|
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
48
48
|
th:nth-child(5), td:nth-child(5) { width: 96px; }
|
|
49
|
-
th:nth-child(6), td:nth-child(6) { width:
|
|
49
|
+
th:nth-child(6), td:nth-child(6) { width: 116px; }
|
|
50
50
|
th:nth-child(7), td:nth-child(7) { width: 66px; }
|
|
51
51
|
th:nth-child(8), td:nth-child(8) { width: 94px; }
|
|
52
52
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
53
53
|
tr:hover { background: #162033; cursor: pointer; }
|
|
54
54
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
55
|
-
.completed, .judged, .
|
|
55
|
+
.completed, .judged, .planned, .batches_completed { background: var(--green); }
|
|
56
56
|
.running, .planning, .judging { background: var(--blue); }
|
|
57
|
-
.failed, .
|
|
57
|
+
.failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
58
58
|
.plan_empty { background: var(--orange); }
|
|
59
59
|
.pending, .created { background: var(--gray); }
|
|
60
60
|
.batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
|
|
@@ -117,7 +117,9 @@
|
|
|
117
117
|
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
118
118
|
.archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
|
|
119
119
|
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
120
|
-
.session-cell {
|
|
120
|
+
.session-cell-wrap { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
|
121
|
+
.session-cell { min-width: 0; font-variant-numeric: tabular-nums; }
|
|
122
|
+
.session-cell-wrap .copy-btn { flex: 0 0 auto; margin-left: 0; }
|
|
121
123
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
122
124
|
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
123
125
|
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
@@ -273,7 +275,6 @@ function userFacingErrorMessage(error) {
|
|
|
273
275
|
const statusText = {
|
|
274
276
|
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
275
277
|
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
276
|
-
workers_completed: '子任务完成', workers_failed: '子任务失败',
|
|
277
278
|
batches_completed: '批次完成', batch_blocked: '批次阻塞', plan_empty: '拆分为空', stopped: '已停止',
|
|
278
279
|
judging: '验收中', judged: '已验收', plan_failed: '拆分失败', judge_failed: '验收失败'
|
|
279
280
|
};
|
|
@@ -586,23 +587,22 @@ function runActionState(key) {
|
|
|
586
587
|
const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
|
|
587
588
|
if (key === 'plan') {
|
|
588
589
|
if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
|
|
589
|
-
if (status === 'planned' || status === 'running' || status === '
|
|
590
|
+
if (status === 'planned' || status === 'running' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
|
|
590
591
|
if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
|
|
591
592
|
return { label: '拆分', disabled: false, state: '' };
|
|
592
593
|
}
|
|
593
594
|
if (key === 'dispatch') {
|
|
594
595
|
if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
|
|
595
596
|
if (status === 'planned') return { label: planApprovalPending() ? '开始执行' : '执行', disabled: false, state: '' };
|
|
596
|
-
if (status === 'batch_blocked') return { label: '
|
|
597
|
-
if (
|
|
598
|
-
if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
597
|
+
if (status === 'batch_blocked') return { label: '重试执行', disabled: false, state: 'retry' };
|
|
598
|
+
if (['batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
599
599
|
return { label: '执行', disabled: true, state: 'done' };
|
|
600
600
|
}
|
|
601
601
|
if (key === 'judge') {
|
|
602
602
|
if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
|
|
603
603
|
if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
|
|
604
604
|
if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
|
|
605
|
-
if (status === 'batches_completed'
|
|
605
|
+
if (status === 'batches_completed') return { label: '验收', disabled: false, state: '' };
|
|
606
606
|
return { label: '验收', disabled: true, state: 'done' };
|
|
607
607
|
}
|
|
608
608
|
if (key === 'stop') {
|
|
@@ -716,7 +716,7 @@ function shortSessionId(thread) {
|
|
|
716
716
|
}
|
|
717
717
|
function sessionCell(thread) {
|
|
718
718
|
if (!thread) return '-';
|
|
719
|
-
return `<span class="session-cell" title="${esc(thread)}">…${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
|
|
719
|
+
return `<span class="session-cell-wrap"><span class="session-cell" title="${esc(thread)}">…${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button></span>`;
|
|
720
720
|
}
|
|
721
721
|
function taskStartedCell(t) {
|
|
722
722
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
@@ -1049,7 +1049,11 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
1049
1049
|
});
|
|
1050
1050
|
}
|
|
1051
1051
|
async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
1052
|
-
async function dispatchRun() {
|
|
1052
|
+
async function dispatchRun() {
|
|
1053
|
+
if (!selectedRun) return;
|
|
1054
|
+
const endpoint = currentState?.status === 'batch_blocked' ? 'retry' : 'dispatch';
|
|
1055
|
+
await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/${endpoint}`, {method:'POST'}); await refreshSelected(); });
|
|
1056
|
+
}
|
|
1053
1057
|
async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
1054
1058
|
async function stopSelectedRun() {
|
|
1055
1059
|
if (!selectedRun) return;
|
package/src/orchestrator.js
CHANGED
|
@@ -500,6 +500,27 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
500
500
|
}];
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
async function rotateJudgeAttempt(state, runDir) {
|
|
504
|
+
const judgeDir = roleDir(runDir, 'judge');
|
|
505
|
+
if (!fs.existsSync(judgeDir)) return;
|
|
506
|
+
const attemptsDir = path.join(runDir, 'judge_attempts');
|
|
507
|
+
await ensureDir(attemptsDir);
|
|
508
|
+
const previousAttempts = (state.judgeAttempts || []).map(item => Number(item.attempt || 0)).filter(Number.isFinite);
|
|
509
|
+
const attempt = Number(state.judge?.attempt || 0) || Math.max(1, 1 + Math.max(0, ...previousAttempts));
|
|
510
|
+
const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
|
|
511
|
+
await fsp.rm(archivedDir, { recursive: true, force: true });
|
|
512
|
+
await fsp.rename(judgeDir, archivedDir);
|
|
513
|
+
state.judgeAttempts = [...(state.judgeAttempts || []), {
|
|
514
|
+
attempt,
|
|
515
|
+
status: state.judge?.status,
|
|
516
|
+
exitCode: state.judge?.exitCode ?? null,
|
|
517
|
+
startedAt: state.judge?.startedAt,
|
|
518
|
+
endedAt: state.judge?.endedAt,
|
|
519
|
+
archivedDir,
|
|
520
|
+
archivedAt: nowIso()
|
|
521
|
+
}];
|
|
522
|
+
}
|
|
523
|
+
|
|
503
524
|
async function rotateWorkerAttempt(state, task) {
|
|
504
525
|
const runDir = pathForRun(state.runId);
|
|
505
526
|
const workerDir = roleDir(runDir, 'worker', task.id);
|
|
@@ -792,16 +813,26 @@ export async function startJudge(runId) {
|
|
|
792
813
|
return await withRunStateLock(runId, async () => {
|
|
793
814
|
const state = await loadRun(runId);
|
|
794
815
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
816
|
+
if (state.archived) throw new Error('archived run cannot be judged');
|
|
817
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be judged');
|
|
795
818
|
recomputeRunStatus(state);
|
|
819
|
+
if (state.judge?.status === 'running') throw new Error('judge already running');
|
|
820
|
+
if (state.judge?.status === 'completed') throw new Error('judge already completed');
|
|
796
821
|
if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
|
|
797
|
-
const
|
|
822
|
+
const runDir = pathForRun(runId);
|
|
823
|
+
if (state.judge?.status === 'failed') await rotateJudgeAttempt(state, runDir);
|
|
824
|
+
const outDir = roleDir(runDir, 'judge');
|
|
798
825
|
await ensureDir(outDir);
|
|
799
826
|
const judgeInputPath = path.join(outDir, 'judge_input.json');
|
|
800
827
|
const judgeInput = await buildJudgeInput(state);
|
|
801
828
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
802
829
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
803
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(
|
|
804
|
-
|
|
830
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
831
|
+
const previousJudgeAttempts = [
|
|
832
|
+
...(state.judgeAttempts || []).map(item => Number(item.attempt || 0)),
|
|
833
|
+
Number(state.judge?.attempt || 0)
|
|
834
|
+
].filter(Number.isFinite);
|
|
835
|
+
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: 1 + Math.max(0, ...previousJudgeAttempts) };
|
|
805
836
|
state.status = 'judging';
|
|
806
837
|
await saveRun(state);
|
|
807
838
|
child.onExit(async code => {
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun,
|
|
|
8
8
|
import { startAutoScheduler } from './scheduler.js';
|
|
9
9
|
|
|
10
10
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
11
|
+
const CODEX_INFO_TTL_MS = 30000;
|
|
12
|
+
let codexInfoCache = null;
|
|
11
13
|
|
|
12
14
|
function send(res, status, body, type = 'application/json') {
|
|
13
15
|
const data = type === 'application/json' ? JSON.stringify(body, null, 2) : body;
|
|
@@ -26,6 +28,13 @@ async function readBody(req) {
|
|
|
26
28
|
function notFound(res) { send(res, 404, { error: 'not found' }); }
|
|
27
29
|
function methodNotAllowed(res) { send(res, 405, { error: 'method not allowed' }); }
|
|
28
30
|
|
|
31
|
+
async function cachedCodexInfo(nowMs = Date.now()) {
|
|
32
|
+
if (codexInfoCache && codexInfoCache.expiresAt > nowMs) return codexInfoCache.value;
|
|
33
|
+
const value = await detectCodexInfo();
|
|
34
|
+
codexInfoCache = { value, expiresAt: nowMs + CODEX_INFO_TTL_MS };
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
async function serveStatic(req, res, pathname) {
|
|
30
39
|
let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
|
|
31
40
|
file = path.resolve(file);
|
|
@@ -46,7 +55,7 @@ async function handleApi(req, res, url, appClient) {
|
|
|
46
55
|
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER, codexBin: CODEX_BIN });
|
|
47
56
|
}
|
|
48
57
|
if (req.method === 'GET' && url.pathname === '/api/codex') {
|
|
49
|
-
return send(res, 200, { ok: true, codex: await
|
|
58
|
+
return send(res, 200, { ok: true, codex: await cachedCodexInfo() });
|
|
50
59
|
}
|
|
51
60
|
if (parts[1] === 'runs' && parts.length === 2) {
|
|
52
61
|
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
|