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/ENVIRONMENT.md +7 -4
- package/PROJECT_GUIDE.md +11 -9
- package/README.en.md +17 -13
- package/README.md +17 -13
- package/RELEASE_NOTES.md +35 -0
- package/bin/input-kanban.js +43 -40
- package/package.json +2 -2
- package/public/index.html +97 -87
- package/src/orchestrator.js +167 -28
- package/src/scheduler.js +40 -0
- package/src/server.js +8 -5
- package/src/utils.js +2 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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('
|
|
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
|
|
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('
|
|
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="
|
|
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('
|
|
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(); }); }
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
203
|
-
if (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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/scheduler.js
ADDED
|
@@ -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
|
+
}
|