input-kanban 0.0.7 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "input-kanban",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "input-kanban": "bin/input-kanban.js"
@@ -9,8 +9,8 @@
9
9
  "start": "node bin/input-kanban.js",
10
10
  "check": "node --check bin/input-kanban.js && node --check bin/input-kanban-format-events.js && node --check bin/input-kanban-timestamp-events.js && node --check bin/input-kanban-tmux-overview.js && node --check src/server.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/utils.js && node --check src/eventFormatter.js && node --check src/runners/index.js && node --check src/runners/headlessRunner.js && node --check src/runners/tmuxRunner.js && node --check src/runners/tmuxUtils.js && node --check src/tmux.js && node --test"
11
11
  },
12
- "dependencies": {},
13
12
  "description": "A local Codex orchestration kanban dashboard",
13
+ "license": "MIT",
14
14
  "files": [
15
15
  "bin",
16
16
  "src",
@@ -19,7 +19,8 @@
19
19
  "README.en.md",
20
20
  "RELEASE_NOTES.md",
21
21
  "PROJECT_GUIDE.md",
22
- "ENVIRONMENT.md"
22
+ "ENVIRONMENT.md",
23
+ "LICENSE"
23
24
  ],
24
25
  "keywords": [
25
26
  "codex",
package/public/index.html CHANGED
@@ -84,11 +84,17 @@
84
84
  .meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85
85
  .meta-chip.long .meta-value { max-width: min(680px, 72vw); }
86
86
  .meta-chip .copy-btn { margin-left: 2px; }
87
+ .refresh-pulse-chip { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 999px; background: #020617; }
88
+ .refresh-pulse-dot { width: 12px; height: 12px; border: 2px solid #60a5fa; border-top-color: transparent; border-radius: 999px; opacity: .62; }
89
+ .refresh-pulse-chip.pulse .refresh-pulse-dot { animation: refresh-spin .8s ease-out; }
90
+ @keyframes refresh-spin { 0% { transform: rotate(0deg) scale(.75); opacity: 1; } 70% { transform: rotate(300deg) scale(1.18); opacity: 1; } 100% { transform: rotate(360deg) scale(1); opacity: .62; } }
87
91
  .log-panel { margin-top: 16px; }
88
92
  .file-tabs button { font-size: 13px; }
89
93
  .copy-btn { padding: 2px 6px; margin: 0 0 0 6px; border-radius: 6px; font-size: 12px; line-height: 1.2; background: var(--gray); vertical-align: middle; }
90
94
  .rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
91
95
  .run-card:hover .rename-btn, .run-card:focus-within .rename-btn, .build-title:hover .rename-btn, .build-title:focus-within .rename-btn, .rename-btn:focus { opacity: 1; pointer-events: auto; }
96
+ .run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
97
+ .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; }
92
98
  .icon-svg { width: 14px; height: 14px; display: block; }
93
99
  .session-cell { word-break: break-all; }
94
100
  .row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
@@ -113,6 +119,7 @@
113
119
  .modal-backdrop.hidden { display: none; }
114
120
  .modal-card { width: min(760px, 100%); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 16px; }
115
121
  .modal-card textarea { min-height: 220px; }
122
+ .page-footer { padding: 0 18px 18px; color: var(--muted); text-align: center; font-size: 12px; }
116
123
  </style>
117
124
  </head>
118
125
  <body>
@@ -180,6 +187,7 @@
180
187
  </section>
181
188
  </div>
182
189
  </main>
190
+ <footer id="pageFooter" class="page-footer">版本:-</footer>
183
191
  <div id="manualCompleteModal" class="modal-backdrop hidden">
184
192
  <div class="modal-card">
185
193
  <h2>手动标记成功</h2>
@@ -198,12 +206,18 @@ let selectedRun = null;
198
206
  let selectedTask = null;
199
207
  let selectedFileName = null;
200
208
  let manualCompleteTaskId = null;
209
+ let pendingArchiveRunId = null;
210
+ const autoDispatchingRuns = new Set();
211
+ const autoJudgingRuns = new Set();
212
+ const autoRetryingRuns = new Set();
213
+ const autoRetrySkippedRuns = new Set();
201
214
  let currentState = null;
202
215
  let lastAutoRefreshAt = null;
203
216
  let runListVisibleCount = 10;
204
217
  let latestRuns = [];
205
218
  const statusByRunId = new Map();
206
219
  const AUTO_REFRESH_MS = 3000;
220
+ const AUTO_MAX_RETRIES = 1;
207
221
  const RUN_LIST_PAGE_SIZE = 10;
208
222
 
209
223
  async function api(path, opts={}) {
@@ -226,6 +240,7 @@ function userFacingErrorMessage(error) {
226
240
  if (/planner already running/i.test(detail)) return '任务拆分正在进行中,请稍后查看结果。';
227
241
  if (/judge already running/i.test(detail)) return '验收正在进行中,请稍后查看结果。';
228
242
  if (/already running/i.test(detail)) return '任务正在进行中,请稍后查看结果。';
243
+ if (/cannot archive.*running/i.test(detail)) return '任务仍在执行中,请先停止后再归档。';
229
244
  return error?.message || String(error);
230
245
  }
231
246
  const statusText = {
@@ -279,6 +294,9 @@ function metaChip(label, value, { title = value, danger = false, long = false, e
279
294
  function editIcon() {
280
295
  return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 16.5V20h3.5L18.1 9.4l-3.5-3.5L4 16.5Z" fill="currentColor"/><path d="m16 4.5 3.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
281
296
  }
297
+ function archiveIcon() {
298
+ return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 5h16v4H4V5Z" fill="currentColor"/><path d="M6 10h12v9H6v-9Z" fill="currentColor" opacity=".72"/><path d="M9 13h6" stroke="#020617" stroke-width="2" stroke-linecap="round"/></svg>';
299
+ }
282
300
  function isTmuxMode() { return currentState?.runner === 'tmux'; }
283
301
  function taskById(id) {
284
302
  if (!currentState) return null;
@@ -319,6 +337,7 @@ async function loadHealth() {
319
337
  const h = await api('/api/health');
320
338
  document.getElementById('repo').value = h.defaultRepo;
321
339
  document.getElementById('runsDir').value = h.runsDir;
340
+ document.getElementById('pageFooter').textContent = h.version ? `版本:v${h.version}` : '版本:未知(请重启服务)';
322
341
  }
323
342
  function showCreateForm() {
324
343
  selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
@@ -346,12 +365,13 @@ async function refreshRuns() {
346
365
  const data = await api('/api/runs');
347
366
  latestRuns = data.runs || [];
348
367
  renderRunList();
368
+ await maybeAutoAdvanceRunSummaries(latestRuns);
349
369
  }
350
370
  function renderRunList() {
351
371
  const visibleRuns = latestRuns.slice(0, runListVisibleCount);
352
372
  const cards = visibleRuns.map(r => `
353
- <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
354
- <div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button></span><span>${pill(r.status)}</span></div>
373
+ <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
374
+ <div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${pill(r.status)}</span></div>
355
375
  <div class="run-card-meta">
356
376
  ${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
357
377
  ${metaChip('创建', formatDateTime(r.createdAt))}
@@ -371,17 +391,19 @@ function showMoreRuns() {
371
391
  renderRunList();
372
392
  }
373
393
  async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFileName = null; clearFileView(); hideCreateForm(); await refreshSelected(); }
374
- async function refreshSelected({auto=false} = {}) {
394
+ async function refreshSelected({auto=false, skipAutoAdvance=false} = {}) {
375
395
  if (!selectedRun) return;
376
396
  currentState = await api(`/api/runs/${selectedRun}/status`);
377
397
  statusByRunId.set(selectedRun, currentState);
378
398
  if (auto) lastAutoRefreshAt = new Date();
379
399
  document.getElementById('selected').innerHTML = renderSelectedHeader();
400
+ if (auto) requestAnimationFrame(triggerRefreshPulse);
380
401
  updateAutoRefreshHint();
381
402
  updateRunNotice();
382
403
  await loadTaskDescription();
383
404
  renderTasks(); await refreshRuns();
384
405
  if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
406
+ if (!skipAutoAdvance) await maybeAutoAdvanceSelectedRun();
385
407
  }
386
408
  function renderSelectedHeader() {
387
409
  if (!currentState) return '<div class="muted">未选择任务批次</div>';
@@ -407,14 +429,24 @@ function renderSelectedHeader() {
407
429
  chips.push(metaChip('终端', 'tmux 现场尚未生成'));
408
430
  }
409
431
  }
410
- chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
411
- chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
432
+ chips.push(refreshPulseChip());
412
433
  return `<div class="build-title"><span>${esc(currentState.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, currentState.runId)">${editIcon()}</button>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
413
434
  }
414
435
  async function loadTaskDescription() {
415
436
  if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
416
437
  document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
417
438
  }
439
+ function refreshPulseChip() {
440
+ const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
441
+ return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
442
+ }
443
+ function triggerRefreshPulse() {
444
+ const el = document.getElementById('refreshPulse');
445
+ if (!el) return;
446
+ el.classList.remove('pulse');
447
+ void el.offsetWidth;
448
+ el.classList.add('pulse');
449
+ }
418
450
  function updateRunNotice() {
419
451
  const el = document.getElementById('runNotice');
420
452
  if (!el) return;
@@ -441,9 +473,9 @@ function updateAutoRefreshHint() {
441
473
  const el = document.getElementById('autoRefreshHint');
442
474
  if (!el) return;
443
475
  if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
444
- const active = ['planning','running','judging'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
476
+ const active = ['planning','running','judging','planned','batches_completed','batch_blocked'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
445
477
  const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
446
- el.textContent = active ? `自动刷新中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新一次|上次刷新 ${last}` : `自动刷新待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
478
+ el.textContent = active ? `自动模式中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新并推进一次|上次刷新 ${last}` : `自动模式待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
447
479
  }
448
480
  function taskStatusCell(t) {
449
481
  if (t?.manualCompletion) {
@@ -780,6 +812,67 @@ async function renameRunLabel(event, runId = selectedRun) {
780
812
  else await refreshRuns();
781
813
  });
782
814
  }
815
+ async function maybeAutoAdvanceRunSummaries(runs) {
816
+ for (const run of runs || []) {
817
+ if (!run || run.runId === selectedRun) continue;
818
+ if (run.status !== 'batch_blocked') autoRetrySkippedRuns.delete(run.runId);
819
+ if (run.status === 'planned') await autoDispatchRun(run.runId, false);
820
+ else if (run.status === 'batches_completed') await autoJudgeRun(run.runId, false);
821
+ else if (run.status === 'batch_blocked') await autoRetryRun(run.runId, false);
822
+ }
823
+ }
824
+ async function autoDispatchRun(runId, refreshSelectedAfter = true) {
825
+ if (!runId || autoDispatchingRuns.has(runId)) return false;
826
+ autoDispatchingRuns.add(runId);
827
+ try {
828
+ await api(`/api/runs/${runId}/dispatch`, {method:'POST'});
829
+ if (refreshSelectedAfter && selectedRun === runId) await refreshSelected({auto:true, skipAutoAdvance:true});
830
+ return true;
831
+ } catch (error) {
832
+ console.error('自动派发失败', error);
833
+ return false;
834
+ } finally {
835
+ autoDispatchingRuns.delete(runId);
836
+ }
837
+ }
838
+ async function autoJudgeRun(runId, refreshSelectedAfter = true) {
839
+ if (!runId || autoJudgingRuns.has(runId)) return false;
840
+ autoJudgingRuns.add(runId);
841
+ try {
842
+ await api(`/api/runs/${runId}/judge`, {method:'POST'});
843
+ if (refreshSelectedAfter && selectedRun === runId) await refreshSelected({auto:true, skipAutoAdvance:true});
844
+ return true;
845
+ } catch (error) {
846
+ console.error('自动验收失败', error);
847
+ return false;
848
+ } finally {
849
+ autoJudgingRuns.delete(runId);
850
+ }
851
+ }
852
+ async function autoRetryRun(runId, refreshSelectedAfter = true) {
853
+ if (!runId || autoRetryingRuns.has(runId) || autoRetrySkippedRuns.has(runId)) return false;
854
+ autoRetryingRuns.add(runId);
855
+ try {
856
+ await api(`/api/runs/${runId}/retry`, {method:'POST', body: JSON.stringify({ reason: 'auto retry from dashboard', maxRetries: AUTO_MAX_RETRIES, auto: true })});
857
+ if (refreshSelectedAfter && selectedRun === runId) await refreshSelected({auto:true, skipAutoAdvance:true});
858
+ return true;
859
+ } catch (error) {
860
+ autoRetrySkippedRuns.add(runId);
861
+ console.error('自动重试失败', error);
862
+ return false;
863
+ } finally {
864
+ autoRetryingRuns.delete(runId);
865
+ }
866
+ }
867
+ async function maybeAutoAdvanceSelectedRun() {
868
+ const runId = selectedRun;
869
+ const state = currentState;
870
+ if (!runId || !state) return;
871
+ if (state.status !== 'batch_blocked') autoRetrySkippedRuns.delete(runId);
872
+ if (state.status === 'planned') await autoDispatchRun(runId);
873
+ else if (state.status === 'batches_completed' && state.judge?.status !== 'running' && state.judge?.status !== 'completed') await autoJudgeRun(runId);
874
+ else if (state.status === 'batch_blocked') await autoRetryRun(runId);
875
+ }
783
876
  async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
784
877
  async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
785
878
  async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
@@ -800,21 +893,46 @@ async function stopSelectedRun() {
800
893
  });
801
894
  await refreshSelected();
802
895
  }
896
+ function clearArchiveConfirm(runId) {
897
+ if (pendingArchiveRunId !== runId) return;
898
+ pendingArchiveRunId = null;
899
+ renderRunList();
900
+ }
901
+ async function archiveRunFromCard(event, runId) {
902
+ event.stopPropagation();
903
+ if (pendingArchiveRunId !== runId) {
904
+ pendingArchiveRunId = runId;
905
+ renderRunList();
906
+ return;
907
+ }
908
+ pendingArchiveRunId = null;
909
+ await archiveRunById(runId, { confirmFirst: false });
910
+ }
911
+ async function archiveRunById(runId, { confirmFirst = true } = {}) {
912
+ if (!runId) return;
913
+ if (confirmFirst) {
914
+ const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
915
+ if (!ok) return;
916
+ }
917
+ await runAction(async () => {
918
+ await api(`/api/runs/${runId}/archive`, {
919
+ method: 'POST',
920
+ body: JSON.stringify({ reason: 'archived from dashboard' })
921
+ });
922
+ if (selectedRun === runId) {
923
+ selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
924
+ document.getElementById('selected').textContent = '未选择任务批次';
925
+ document.getElementById('taskDescription').textContent = '未选择任务批次';
926
+ document.getElementById('tasks').innerHTML = '';
927
+ clearFileView();
928
+ updateAutoRefreshHint();
929
+ }
930
+ await refreshRuns();
931
+ });
932
+ }
803
933
  async function archiveSelectedRun() {
804
934
  if (!selectedRun) return;
805
- const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
806
- if (!ok) return;
807
- await api(`/api/runs/${selectedRun}/archive`, {
808
- method: 'POST',
809
- body: JSON.stringify({ reason: 'archived from dashboard' })
810
- });
811
- selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
812
- document.getElementById('selected').textContent = '未选择任务批次';
813
- document.getElementById('taskDescription').textContent = '未选择任务批次';
814
- document.getElementById('tasks').innerHTML = '';
815
- clearFileView();
816
- updateAutoRefreshHint();
817
- await refreshRuns();
935
+ await archiveRunById(selectedRun);
818
936
  }
819
937
  async function markTaskCompleted(event, taskId) {
820
938
  event.stopPropagation();