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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
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: 34%; }
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: 92px; }
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, .workers_completed, .planned, .batches_completed { background: var(--green); }
55
+ .completed, .judged, .planned, .batches_completed { background: var(--green); }
56
56
  .running, .planning, .judging { background: var(--blue); }
57
- .failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
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 { word-break: break-all; }
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 === 'workers_completed' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
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: '执行', disabled: false, state: 'retry' };
597
- if (status === 'workers_failed') return { label: '重试执行', disabled: false, state: 'retry' };
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' || status === 'workers_completed') return { label: '验收', disabled: false, state: '' };
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() { if (selectedRun) await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
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;
@@ -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 outDir = roleDir(pathForRun(runId), 'judge');
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(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
804
- state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
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 detectCodexInfo() });
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') || '' }) });