input-kanban 0.0.9 → 0.0.10

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/public/index.html CHANGED
@@ -22,8 +22,8 @@
22
22
  .sidebar { position: sticky; top: 18px; height: calc(100vh - 36px); }
23
23
  .sidebar section { height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
24
24
  section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
25
- textarea, input { width: 100%; box-sizing: border-box; background: #020617; color: var(--text); border: 1px solid #475569; border-radius: 9px; padding: 9px 10px; outline: none; }
26
- textarea:focus, input:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
25
+ textarea, input, select { width: 100%; box-sizing: border-box; background: #020617; color: var(--text); border: 1px solid #475569; border-radius: 9px; padding: 9px 10px; outline: none; }
26
+ textarea:focus, input:focus, select:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
27
27
  textarea { min-height: 240px; }
28
28
  label { display: block; margin-top: 10px; color: #cbd5e1; font-weight: 700; }
29
29
  button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; }
@@ -60,8 +60,12 @@
60
60
  .muted { color: var(--muted); font-size: 12px; }
61
61
  .hidden { display: none; }
62
62
  .toolbar { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
63
+ .workspace-filter-panel { margin: 4px 0 10px; display: flex; align-items: center; gap: 6px; }
64
+ .workspace-filter-select { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 7px 9px; color: #cbd5e1; }
63
65
  .task-text { max-height: 180px; color: #cbd5e1; }
64
66
  .empty { color: var(--muted); padding: 18px 0; }
67
+ .runs-load-icon { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 10px; cursor: help; opacity: .72; }
68
+ .runs-load-icon:hover { opacity: 1; color: #cbd5e1; border-color: var(--line-strong); }
65
69
  .run-list { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
66
70
  .run-list-more { width: 100%; margin-top: 4px; }
67
71
  .run-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: var(--panel-2); cursor: pointer; transition: border-color .15s, transform .15s, background .15s; }
@@ -129,21 +133,24 @@
129
133
  <section>
130
134
  <div class="toolbar">
131
135
  <button onclick="showCreateForm()">新建任务批次</button>
132
- <button class="secondary" onclick="refreshRuns()">刷新批次列表</button>
133
136
  </div>
134
137
  <h2>任务批次</h2>
135
- <div id="runs" class="run-list"></div>
138
+ <div class="workspace-filter-panel">
139
+ <select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
140
+ <span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
141
+ </div>
142
+ <div id="runs" class="run-list"><div class="empty">批次列表加载中...</div></div>
136
143
  </section>
137
144
  </div>
138
145
  <div>
139
146
  <section id="createPanel" class="hidden">
140
147
  <h2>新建任务批次</h2>
141
148
  <label>批次名称</label><input id="label" value="codex-task" />
142
- <label>目标仓库</label><input id="repo" />
149
+ <label>工作区</label><input id="repo" />
143
150
  <label>运行目录</label><input id="runsDir" readonly />
144
151
  <label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
145
152
  <label>Worker 沙箱</label><select id="workerSandbox">
146
- <option value="workspace-write" selected>workspace-write(默认,允许写当前仓库)</option>
153
+ <option value="workspace-write" selected>workspace-write(默认,允许写当前工作区)</option>
147
154
  <option value="read-only">read-only(只读)</option>
148
155
  <option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
149
156
  </select>
@@ -207,18 +214,17 @@ let selectedTask = null;
207
214
  let selectedFileName = null;
208
215
  let manualCompleteTaskId = null;
209
216
  let pendingArchiveRunId = null;
210
- const autoDispatchingRuns = new Set();
211
- const autoJudgingRuns = new Set();
212
- const autoRetryingRuns = new Set();
213
- const autoRetrySkippedRuns = new Set();
214
217
  let currentState = null;
215
218
  let lastAutoRefreshAt = null;
216
219
  let runListVisibleCount = 10;
217
220
  let latestRuns = [];
221
+ let workspaceCatalogRuns = [];
218
222
  const statusByRunId = new Map();
219
223
  const AUTO_REFRESH_MS = 3000;
220
- const AUTO_MAX_RETRIES = 1;
221
224
  const RUN_LIST_PAGE_SIZE = 10;
225
+ const WORKSPACE_FILTER_ALL = '';
226
+ let currentWorkspacePath = '';
227
+ let selectedWorkspaceFilter = localStorage.getItem('input-kanban.workspaceFilter') || WORKSPACE_FILTER_ALL;
222
228
 
223
229
  async function api(path, opts={}) {
224
230
  const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
@@ -291,6 +297,7 @@ function basenamePath(value) {
291
297
  function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
292
298
  return `<span class="meta-chip ${danger ? 'danger' : ''} ${long ? 'long' : ''}" title="${esc(title)}"><span class="meta-label">${esc(label)}</span><span class="meta-value">${esc(value)}</span>${extra}</span>`;
293
299
  }
300
+ function gitChip() { return '<span class="meta-chip" title="Git 工作区"><span class="meta-value">Git</span></span>'; }
294
301
  function editIcon() {
295
302
  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>';
296
303
  }
@@ -335,9 +342,12 @@ function runListHasTmuxMetadata(run) {
335
342
 
336
343
  async function loadHealth() {
337
344
  const h = await api('/api/health');
338
- document.getElementById('repo').value = h.defaultRepo;
345
+ currentWorkspacePath = h.defaultWorkspace || h.defaultRepo || '';
346
+ document.getElementById('repo').value = currentWorkspacePath;
339
347
  document.getElementById('runsDir').value = h.runsDir;
340
348
  document.getElementById('pageFooter').textContent = h.version ? `版本:v${h.version}` : '版本:未知(请重启服务)';
349
+ renderWorkspaceFilterOptions();
350
+ updateWorkspaceFilterTitle();
341
351
  }
342
352
  function showCreateForm() {
343
353
  selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
@@ -352,7 +362,7 @@ function hideCreateForm() {
352
362
  document.getElementById('filePanel').classList.remove('hidden');
353
363
  }
354
364
  async function createRun() {
355
- const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
365
+ const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
356
366
  const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
357
367
  selectedRun = r.runId; selectedTask = null; selectedFileName = null;
358
368
  clearFileView();
@@ -362,10 +372,28 @@ async function createRun() {
362
372
  await refreshSelected();
363
373
  }
364
374
  async function refreshRuns() {
365
- const data = await api('/api/runs');
366
- latestRuns = data.runs || [];
367
- renderRunList();
368
- await maybeAutoAdvanceRunSummaries(latestRuns);
375
+ const startedAt = performance.now();
376
+ const hint = document.getElementById('runsLoadHint');
377
+ if (hint) { hint.title = '批次列表加载中...'; hint.setAttribute('aria-label', hint.title); }
378
+ if (!latestRuns.length) document.getElementById('runs').innerHTML = '<div class="empty">批次列表加载中...</div>';
379
+ try {
380
+ const params = new URLSearchParams();
381
+ if (selectedWorkspaceFilter && selectedWorkspaceFilter !== WORKSPACE_FILTER_ALL) params.set('workspace', selectedWorkspaceFilter);
382
+ const [filteredData, catalogData] = await Promise.all([
383
+ api(`/api/runs${params.toString() ? `?${params.toString()}` : ''}`),
384
+ api('/api/runs?includeArchived=1')
385
+ ]);
386
+ latestRuns = filteredData.runs || [];
387
+ workspaceCatalogRuns = catalogData.runs || [];
388
+ renderWorkspaceFilterOptions();
389
+ renderRunList();
390
+ updateWorkspaceFilterTitle();
391
+ if (hint) { hint.title = `加载 ${Math.round(performance.now() - startedAt)}ms|显示 ${latestRuns.length} 个批次`; hint.setAttribute('aria-label', hint.title); }
392
+ } catch (error) {
393
+ console.error('批次列表加载失败', error);
394
+ if (hint) { hint.title = `批次列表加载失败|${errorDetail(error)}`; hint.setAttribute('aria-label', hint.title); }
395
+ if (!latestRuns.length) document.getElementById('runs').innerHTML = '<div class="empty">批次列表加载失败</div>';
396
+ }
369
397
  }
370
398
  function renderRunList() {
371
399
  const visibleRuns = latestRuns.slice(0, runListVisibleCount);
@@ -373,7 +401,8 @@ function renderRunList() {
373
401
  <div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
374
402
  <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>
375
403
  <div class="run-card-meta">
376
- ${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
404
+ ${metaChip('工作区', basenamePath(r.workspacePath || r.repo), { title: r.workspacePath || r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制工作区地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
405
+ ${r.git?.isGit ? gitChip() : ''}
377
406
  ${metaChip('创建', formatDateTime(r.createdAt))}
378
407
  ${metaChip('用时', runCardDurationText(r))}
379
408
  ${metaChip('进度', `${r.completed}/${r.total}`)}
@@ -390,8 +419,48 @@ function showMoreRuns() {
390
419
  runListVisibleCount += RUN_LIST_PAGE_SIZE;
391
420
  renderRunList();
392
421
  }
422
+ function renderWorkspaceFilterOptions() {
423
+ const select = document.getElementById('workspaceFilterSelect');
424
+ if (!select) return;
425
+ const workspaces = new Map();
426
+ for (const run of workspaceCatalogRuns) {
427
+ const workspacePath = run.workspacePath || run.repo || '';
428
+ if (!workspacePath) continue;
429
+ const item = workspaces.get(workspacePath) || { label: basenamePath(workspacePath), count: 0, git: !!run.git?.isGit };
430
+ item.count += 1;
431
+ item.git = item.git || !!run.git?.isGit;
432
+ workspaces.set(workspacePath, item);
433
+ }
434
+ const currentValue = workspaces.has(selectedWorkspaceFilter) ? selectedWorkspaceFilter : WORKSPACE_FILTER_ALL;
435
+ if (currentValue !== selectedWorkspaceFilter) {
436
+ selectedWorkspaceFilter = currentValue;
437
+ localStorage.setItem('input-kanban.workspaceFilter', selectedWorkspaceFilter);
438
+ }
439
+ const options = [[WORKSPACE_FILTER_ALL, '工作区筛选'], ...[...workspaces.entries()].map(([workspacePath, item]) => {
440
+ const gitText = item.git ? ' · Git' : '';
441
+ return [workspacePath, `${item.label}${gitText} (${item.count})`];
442
+ })];
443
+ select.innerHTML = options.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join('');
444
+ select.value = selectedWorkspaceFilter;
445
+ updateWorkspaceFilterTitle();
446
+ }
447
+ function updateWorkspaceFilterTitle() {
448
+ const select = document.getElementById('workspaceFilterSelect');
449
+ if (!select) return;
450
+ if (selectedWorkspaceFilter === WORKSPACE_FILTER_ALL) {
451
+ select.title = currentWorkspacePath ? `未筛选工作区|默认工作区:${currentWorkspacePath}` : '未筛选工作区';
452
+ } else {
453
+ select.title = `当前筛选:${selectedWorkspaceFilter}`;
454
+ }
455
+ }
456
+ function setWorkspaceFilter(value) {
457
+ selectedWorkspaceFilter = String(value || '').trim();
458
+ localStorage.setItem('input-kanban.workspaceFilter', selectedWorkspaceFilter);
459
+ runListVisibleCount = RUN_LIST_PAGE_SIZE;
460
+ refreshRuns().catch(console.error);
461
+ }
393
462
  async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFileName = null; clearFileView(); hideCreateForm(); await refreshSelected(); }
394
- async function refreshSelected({auto=false, skipAutoAdvance=false} = {}) {
463
+ async function refreshSelected({auto=false} = {}) {
395
464
  if (!selectedRun) return;
396
465
  currentState = await api(`/api/runs/${selectedRun}/status`);
397
466
  statusByRunId.set(selectedRun, currentState);
@@ -403,22 +472,24 @@ async function refreshSelected({auto=false, skipAutoAdvance=false} = {}) {
403
472
  await loadTaskDescription();
404
473
  renderTasks(); await refreshRuns();
405
474
  if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
406
- if (!skipAutoAdvance) await maybeAutoAdvanceSelectedRun();
407
475
  }
408
476
  function renderSelectedHeader() {
409
477
  if (!currentState) return '<div class="muted">未选择任务批次</div>';
410
478
  const sandbox = currentState.workerSandbox || 'workspace-write';
411
479
  const chips = [
412
480
  metaChip('Run ID', currentState.runId, { long: true }),
413
- metaChip('仓库', basenamePath(currentState.repo), {
414
- title: currentState.repo,
481
+ metaChip('工作区', basenamePath(currentState.workspacePath || currentState.repo), {
482
+ title: currentState.workspacePath || currentState.repo,
415
483
  long: true,
416
- extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRepoPath(event)">⧉</button>`
484
+ extra: `<button class="secondary copy-btn" title="复制工作区地址" onclick="copyRepoPath(event)">⧉</button>`
417
485
  }),
418
486
  metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
419
487
  metaChip('开始', formatDateTime(currentState.createdAt)),
420
488
  metaChip('用时', formatDurationMs(durationSeconds(currentState.createdAt, runDurationEnd(currentState)) * 1000))
421
489
  ];
490
+ if (currentState.git?.isGit || currentState.workspace?.git?.isGit) {
491
+ chips.push(gitChip());
492
+ }
422
493
  if (currentState.runner === 'tmux') {
423
494
  if (hasRunTmuxMetadata(currentState)) {
424
495
  chips.push(metaChip('终端', tmuxSessionName(currentState), {
@@ -633,7 +704,7 @@ function hideExecutionSummary() {
633
704
  el.classList.add('hidden');
634
705
  el.innerHTML = '';
635
706
  }
636
- async function copyRepoPath(event, repoPath = currentState?.repo || '') {
707
+ async function copyRepoPath(event, repoPath = currentState?.workspacePath || currentState?.repo || '') {
637
708
  event.stopPropagation();
638
709
  if (!repoPath) return;
639
710
  try {
@@ -641,11 +712,11 @@ async function copyRepoPath(event, repoPath = currentState?.repo || '') {
641
712
  event.currentTarget.textContent = '已复制';
642
713
  setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
643
714
  } catch {
644
- prompt('复制仓库地址', repoPath);
715
+ prompt('复制工作区地址', repoPath);
645
716
  }
646
717
  }
647
718
  async function copyRunRepoPath(event, runId) {
648
- const repoPath = latestRuns.find(run => run.runId === runId)?.repo || '';
719
+ const repoPath = latestRuns.find(run => run.runId === runId)?.workspacePath || latestRuns.find(run => run.runId === runId)?.repo || '';
649
720
  await copyRepoPath(event, repoPath);
650
721
  }
651
722
  async function copyTmuxRunCommand(event) {
@@ -812,67 +883,6 @@ async function renameRunLabel(event, runId = selectedRun) {
812
883
  else await refreshRuns();
813
884
  });
814
885
  }
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
- }
876
886
  async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
877
887
  async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
878
888
  async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
@@ -4,7 +4,7 @@ import fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import {
7
- DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
7
+ DEFAULT_WORKSPACE, DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
8
8
  writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
9
9
  pathForRun, roleDir, safeIdPart, RUNNER
10
10
  } from './utils.js';
@@ -31,6 +31,61 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
31
31
  function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
32
32
  function planPath(runDir) { return path.join(runDir, 'plan.json'); }
33
33
  function lockPath(runDir) { return path.join(runDir, RUN_STATE_LOCK_NAME); }
34
+ function workspacePathOf(state) { return path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO); }
35
+ function workspaceNameOf(state) { return state?.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state); }
36
+ async function detectWorkspaceMetadata(workspacePath) {
37
+ const resolvedWorkspace = path.resolve(workspacePath || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
38
+ const metadata = {
39
+ path: resolvedWorkspace,
40
+ name: path.basename(resolvedWorkspace) || resolvedWorkspace,
41
+ isGit: false
42
+ };
43
+ try {
44
+ const { stdout: rootStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
45
+ const gitRoot = rootStdout.trim();
46
+ if (gitRoot) {
47
+ metadata.isGit = true;
48
+ metadata.gitRoot = gitRoot;
49
+ try {
50
+ const { stdout: branchStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'branch', '--show-current'], { timeout: 5000 });
51
+ metadata.branch = branchStdout.trim() || 'detached';
52
+ } catch {
53
+ metadata.branch = 'unknown';
54
+ }
55
+ try {
56
+ const { stdout: dirtyStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'status', '--porcelain'], { timeout: 5000 });
57
+ metadata.dirty = dirtyStdout.trim().length > 0;
58
+ } catch {
59
+ metadata.dirty = null;
60
+ }
61
+ }
62
+ } catch {}
63
+ return metadata;
64
+ }
65
+ async function assertWorkspacePath(workspace) {
66
+ const resolvedWorkspace = path.resolve(workspace || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
67
+ let stat;
68
+ try { stat = await fsp.stat(resolvedWorkspace); }
69
+ catch { throw userInputError(`workspace does not exist: ${resolvedWorkspace}`); }
70
+ if (!stat.isDirectory()) throw userInputError(`workspace is not a directory: ${resolvedWorkspace}`);
71
+ return resolvedWorkspace;
72
+ }
73
+ function normalizeWorkspaceState(state) {
74
+ const workspacePath = path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO);
75
+ const workspaceName = state.workspaceName || path.basename(workspacePath) || workspacePath;
76
+ const git = state.git || state.workspace?.git || null;
77
+ state.workspacePath = workspacePath;
78
+ state.workspaceName = workspaceName;
79
+ state.repo = state.repo || workspacePath;
80
+ state.git = git;
81
+ state.workspace = state.workspace || {
82
+ path: workspacePath,
83
+ name: workspaceName,
84
+ git
85
+ };
86
+ if (!state.workspace.git && git) state.workspace.git = git;
87
+ return state;
88
+ }
34
89
 
35
90
  async function isStaleRunLock(lockFile) {
36
91
  const info = await fileInfo(lockFile);
@@ -165,28 +220,29 @@ function deriveRunLabel(label, taskText) {
165
220
  return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
166
221
  }
167
222
 
168
- async function assertGitWorkTree(repo) {
169
- const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
170
- let stat;
171
- try { stat = await fsp.stat(resolvedRepo); }
172
- catch { throw userInputError(`target repository does not exist: ${resolvedRepo}`); }
173
- if (!stat.isDirectory()) throw userInputError(`target repository is not a directory: ${resolvedRepo}`);
174
- try {
175
- const { stdout } = await execFileAsync('git', ['-C', resolvedRepo, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
176
- if (stdout.trim() === 'true') return resolvedRepo;
177
- } catch {}
178
- throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
179
- }
180
223
 
181
- export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
182
- const resolvedRepo = await assertGitWorkTree(repo);
224
+ export async function createRun({ label = '', taskText = '', workspace = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
225
+ const resolvedWorkspace = await assertWorkspacePath(workspace || repo || DEFAULT_WORKSPACE);
226
+ const workspaceMeta = await detectWorkspaceMetadata(resolvedWorkspace);
183
227
  const runLabel = deriveRunLabel(label, taskText);
184
228
  const runId = makeRunId(runLabel);
185
229
  const runDir = pathForRun(runId);
186
230
  await ensureDir(runDir);
187
231
  await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
188
232
  const state = {
189
- runId, label: runLabel, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
233
+ runId,
234
+ label: runLabel,
235
+ workspacePath: resolvedWorkspace,
236
+ workspaceName: workspaceMeta.name,
237
+ workspace: {
238
+ path: resolvedWorkspace,
239
+ name: workspaceMeta.name,
240
+ git: workspaceMeta
241
+ },
242
+ git: workspaceMeta,
243
+ repo: resolvedWorkspace,
244
+ maxParallel: Number(maxParallel) || 3,
245
+ workerSandbox: normalizeSandbox(workerSandbox),
190
246
  runner: RUNNER,
191
247
  status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
192
248
  planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
@@ -195,23 +251,57 @@ export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO
195
251
  return state;
196
252
  }
197
253
 
198
- export async function listRuns({ includeArchived = false } = {}) {
254
+ function normalizeWorkspaceFilter(workspace) {
255
+ const value = String(workspace || '').trim();
256
+ if (!value || value === 'all') return '';
257
+ return path.resolve(value);
258
+ }
259
+
260
+ function runMatchesWorkspace(run, workspaceFilter) {
261
+ if (!workspaceFilter) return true;
262
+ const runWorkspace = path.resolve(run?.workspacePath || run?.repo || '');
263
+ return runWorkspace === workspaceFilter;
264
+ }
265
+
266
+ export function isTerminalRunStatus(status) {
267
+ return ['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
268
+ }
269
+
270
+ export function isFailureRunStatus(status) {
271
+ return ['judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
272
+ }
273
+
274
+ export async function listRuns({ includeArchived = false, workspace = '' } = {}) {
275
+ const workspaceFilter = normalizeWorkspaceFilter(workspace);
199
276
  const dirs = await listRunDirs();
200
277
  const rows = [];
201
278
  for (const dir of dirs) {
202
- const s = await loadAndRefreshRun(path.basename(dir), null, { light: true });
203
- if (s && (includeArchived || !s.archived)) rows.push(summaryOfRun(s));
279
+ const s = await loadRun(path.basename(dir));
280
+ if (!s || (!includeArchived && s.archived)) continue;
281
+ const summary = summaryOfRun(s);
282
+ if (!runMatchesWorkspace(summary, workspaceFilter)) continue;
283
+ rows.push(summary);
204
284
  }
205
285
  return rows;
206
286
  }
207
287
 
208
288
  export async function loadRun(runId) {
209
289
  const state = await readJson(statePath(pathForRun(runId)), null);
210
- if (state) ensureBatchShape(state);
290
+ if (state) {
291
+ normalizeWorkspaceState(state);
292
+ if (!state.git || typeof state.git.isGit !== 'boolean') {
293
+ const workspaceMeta = await detectWorkspaceMetadata(workspacePathOf(state));
294
+ state.git = workspaceMeta;
295
+ state.workspace = state.workspace || { path: workspacePathOf(state), name: state.workspaceName, git: workspaceMeta };
296
+ state.workspace.git = workspaceMeta;
297
+ }
298
+ ensureBatchShape(state);
299
+ }
211
300
  return state;
212
301
  }
213
302
 
214
303
  async function saveRun(state) {
304
+ normalizeWorkspaceState(state);
215
305
  ensureBatchShape(state);
216
306
  state.updatedAt = nowIso();
217
307
  await writeJsonAtomic(statePath(pathForRun(state.runId)), state);
@@ -319,7 +409,7 @@ export async function startPlanner(runId) {
319
409
  await fsp.rm(planPath(runDir), { force: true });
320
410
  const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
321
411
  const prompt = defaultPlannerPrompt(state, taskText);
322
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
412
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
323
413
  state.status = 'planning';
324
414
  state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
325
415
  await saveRun(state);
@@ -499,7 +589,7 @@ export async function dispatchRun(runId) {
499
589
  }
500
590
 
501
591
  function artifactPathForState(state, rel) {
502
- return path.isAbsolute(rel) ? rel : path.join(state.repo, rel);
592
+ return path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel);
503
593
  }
504
594
 
505
595
  function workerArtifactInstructions(state, task) {
@@ -529,7 +619,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
529
619
 
530
620
  ${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
531
621
  `;
532
- const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state.repo, outDir });
622
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: workspacePathOf(state), outDir });
533
623
  Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
534
624
  }
535
625
 
@@ -688,7 +778,7 @@ export async function startJudge(runId) {
688
778
  const judgeInput = await buildJudgeInput(state);
689
779
  await writeJsonAtomic(judgeInputPath, judgeInput);
690
780
  const prompt = defaultJudgePrompt(state, judgeInputPath);
691
- const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state.repo, outDir });
781
+ const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
692
782
  state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
693
783
  state.status = 'judging';
694
784
  await saveRun(state);
@@ -711,6 +801,50 @@ export async function refreshRun(runId, appClient = null) {
711
801
  return await loadAndRefreshRun(runId, appClient, { light: false });
712
802
  }
713
803
 
804
+ export async function autoAdvanceRun(runId, { appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
805
+ let state = await refreshRun(runId, appClient);
806
+ if (!state || state.archived || state.status === 'stopped') return state;
807
+ if (startCreated && state.status === 'created') {
808
+ try { state = await startPlanner(runId); }
809
+ catch (error) { if (!/planner already running/i.test(error.message || '')) throw error; }
810
+ state = await refreshRun(runId, appClient);
811
+ }
812
+ if (!state || state.archived || state.status === 'stopped') return state;
813
+ if (state.status === 'batch_blocked') {
814
+ try { state = await retryRun(runId, { reason: retryReason, maxRetries, auto: true }); }
815
+ catch (error) { state.autoAdvanceError = error.message || String(error); }
816
+ return await refreshRun(runId, appClient) || state;
817
+ }
818
+ if (state.status === 'planned') {
819
+ try { state = await dispatchRun(runId); }
820
+ catch (error) { if (!/all batches completed|current batch is blocked/i.test(error.message || '')) throw error; }
821
+ return await refreshRun(runId, appClient) || state;
822
+ }
823
+ if (state.status === 'batches_completed' && state.judge?.status !== 'running' && state.judge?.status !== 'completed') {
824
+ try { state = await startJudge(runId); }
825
+ catch (error) { if (!/final judge is allowed only after all batches completed/i.test(error.message || '')) throw error; }
826
+ return await refreshRun(runId, appClient) || state;
827
+ }
828
+ return state;
829
+ }
830
+
831
+ export async function autoAdvanceActiveRuns({ appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
832
+ const dirs = await listRunDirs();
833
+ const results = [];
834
+ for (const dir of dirs) {
835
+ const runId = path.basename(dir);
836
+ try {
837
+ const initial = await loadRun(runId);
838
+ if (!initial || initial.archived || ['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(initial.status)) continue;
839
+ const state = await autoAdvanceRun(runId, { appClient, startCreated, maxRetries, retryReason });
840
+ if (state) results.push({ runId, status: state.status, ok: true });
841
+ } catch (error) {
842
+ results.push({ runId, ok: false, error: error.message || String(error) });
843
+ }
844
+ }
845
+ return results;
846
+ }
847
+
714
848
  async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
715
849
  return await withRunStateLock(runId, async () => {
716
850
  const state = await loadRun(runId);
@@ -796,7 +930,7 @@ async function refreshTask(state, task) {
796
930
  await attachTmuxMetadata(task, dir);
797
931
  delete task.attentionHint;
798
932
  task.artifacts = [];
799
- for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state.repo, rel))) });
933
+ for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel))) });
800
934
  const batch = (state.batches || []).find(b => b.id === task.batchId);
801
935
  if (batch) {
802
936
  const bt = batch.tasks.find(t => t.id === task.id);
@@ -1021,7 +1155,10 @@ async function buildJudgeInput(state) {
1021
1155
  run: {
1022
1156
  runId: state.runId,
1023
1157
  label: state.label,
1024
- repo: state.repo,
1158
+ repo: workspacePathOf(state),
1159
+ workspacePath: workspacePathOf(state),
1160
+ workspaceName: state.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state),
1161
+ git: state.git || state.workspace?.git || null,
1025
1162
  status: state.status,
1026
1163
  runner: state.runner || RUNNER,
1027
1164
  createdAt: state.createdAt,
@@ -1065,7 +1202,7 @@ function ensureBatchShape(state) {
1065
1202
  }
1066
1203
 
1067
1204
  async function enrichFromAppServer(state, appClient) {
1068
- const res = await appClient.listThreads({ cwd: state.repo, limit: 100 });
1205
+ const res = await appClient.listThreads({ cwd: workspacePathOf(state), limit: 100 });
1069
1206
  const threads = res?.data || [];
1070
1207
  const all = [{ id: 'planner', target: state.planner }, ...(state.tasks || []).map(t => ({ id: t.id, target: t })), { id: 'judge', target: state.judge }];
1071
1208
  for (const item of all) {
@@ -1089,7 +1226,9 @@ function runDurationEndOfState(s) {
1089
1226
 
1090
1227
  export function summaryOfRun(s) {
1091
1228
  const tasks = s.tasks || [];
1092
- return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
1229
+ const workspacePath = s.workspacePath || s.repo || '';
1230
+ const git = s.git || s.workspace?.git || null;
1231
+ return { runId: s.runId, label: s.label, repo: s.repo || workspacePath, workspacePath, workspaceName: s.workspaceName || path.basename(workspacePath || ''), git, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
1093
1232
  }
1094
1233
 
1095
1234
  export async function readRunTaskText(runId) {
@@ -0,0 +1,40 @@
1
+ import { autoAdvanceActiveRuns } from './orchestrator.js';
2
+
3
+ export function startAutoScheduler({ appClient = null, pollMs = Number(process.env.KANBAN_AUTO_POLL_MS || 3000), maxRetries = Number(process.env.KANBAN_AUTO_MAX_RETRIES || 1), startCreated = false, log = false } = {}) {
4
+ const intervalMs = Math.max(500, Number(pollMs) || 3000);
5
+ let stopped = false;
6
+ let running = false;
7
+ let timer = null;
8
+
9
+ const tick = async () => {
10
+ if (stopped || running) return;
11
+ running = true;
12
+ try {
13
+ const results = await autoAdvanceActiveRuns({ appClient, startCreated, maxRetries, retryReason: 'auto retry from server scheduler' });
14
+ if (log) {
15
+ for (const result of results) {
16
+ if (result.ok === false) console.warn(`[scheduler] ${result.runId}: ${result.error}`);
17
+ }
18
+ }
19
+ } catch (error) {
20
+ if (log) console.warn(`[scheduler] ${error.message || String(error)}`);
21
+ } finally {
22
+ running = false;
23
+ }
24
+ };
25
+
26
+ timer = setInterval(() => { tick(); }, intervalMs);
27
+ timer.unref?.();
28
+ tick();
29
+
30
+ return {
31
+ get running() { return running; },
32
+ get stopped() { return stopped; },
33
+ async tick() { await tick(); },
34
+ stop() {
35
+ stopped = true;
36
+ if (timer) clearInterval(timer);
37
+ timer = null;
38
+ }
39
+ };
40
+ }