input-kanban 0.0.12 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +18 -0
- package/bin/input-kanban.js +1 -1
- package/package.json +2 -2
- package/public/index.html +151 -34
- package/src/appServerClient.js +67 -15
- package/src/codexLauncher.js +88 -0
- package/src/orchestrator.js +2 -0
- package/src/runners/headlessRunner.js +22 -5
- package/src/runners/tmuxRunner.js +11 -4
- package/src/server.js +5 -2
- package/src/utils.js +48 -0
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.0.13
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Harden Codex launching on Windows by resolving npm `codex.cmd` shims and explicit JavaScript launchers through a shared `resolveCodexLauncher()` adapter.
|
|
8
|
+
- Use the shared Codex launcher path from the app-server client, headless runner, tmux runner, and Web footer Codex detection.
|
|
9
|
+
- Add `/api/codex` and a compact Web footer Codex status that shows the backend-visible CLI version, for example `codex-cli 0.139.0`, without relying on npm registry `latest` by default.
|
|
10
|
+
- Improve Web action feedback by turning run action buttons into lightweight state indicators: pending actions disable immediately, active backend states pulse subtly, and retry/done states use concise labels.
|
|
11
|
+
- Keep `batch_blocked` runs discoverable via `input-kanban runs --active`, so agent/CLI auto loops can continue recoverable work instead of hiding blocked batches.
|
|
12
|
+
- Make retry preparation atomic when selected tasks include a live process: no worker attempt is archived until all selected tasks are confirmed safe to retry.
|
|
13
|
+
- Add Windows-focused regression coverage for Codex launcher resolution, app-server spawn failures, headless spawn failures, and tmux launcher quoting.
|
|
14
|
+
|
|
15
|
+
### Verification
|
|
16
|
+
|
|
17
|
+
- `npm run check` passed locally with 76 tests.
|
|
18
|
+
- `npm run check` passed on the remote Windows validation host `zhangxing_win` with 76 tests after installing `@openai/codex` CLI.
|
|
19
|
+
- Windows backend Codex detection returned `codex-cli 0.139.0` through `detectCodexInfo()`.
|
|
20
|
+
|
|
3
21
|
## v0.0.12
|
|
4
22
|
|
|
5
23
|
### Highlights
|
package/bin/input-kanban.js
CHANGED
|
@@ -509,7 +509,7 @@ function isFailureTerminal(state) {
|
|
|
509
509
|
function isActiveRunSummary(run) {
|
|
510
510
|
if (!run) return false;
|
|
511
511
|
if (Number(run.running) > 0) return true;
|
|
512
|
-
return !['judged', 'judge_failed', '
|
|
512
|
+
return !['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(run.status);
|
|
513
513
|
}
|
|
514
514
|
|
|
515
515
|
function hasRecoverableUnknownTask(state) {
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "input-kanban",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"input-kanban": "bin/input-kanban.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "node bin/input-kanban.js",
|
|
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/scheduler.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"
|
|
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/scheduler.js && node --check src/orchestrator.js && node --check src/appServerClient.js && node --check src/codexLauncher.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 --check test/app-server-client.test.js && node --check test/app-server-client-stop.test.js && node --test"
|
|
11
11
|
},
|
|
12
12
|
"description": "A local Codex orchestration kanban dashboard",
|
|
13
13
|
"license": "MIT",
|
package/public/index.html
CHANGED
|
@@ -26,10 +26,19 @@
|
|
|
26
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
|
-
button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; }
|
|
30
|
-
button:hover { filter: brightness(1.08); }
|
|
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; transition: filter .15s, opacity .15s, transform .15s, box-shadow .15s; }
|
|
30
|
+
button:hover:not(:disabled) { filter: brightness(1.08); }
|
|
31
|
+
button:disabled { cursor: default; opacity: .72; }
|
|
31
32
|
button.secondary { background: var(--gray); }
|
|
32
33
|
button.danger { background: #dc2626; }
|
|
34
|
+
button.state-pending { position: relative; opacity: .82; }
|
|
35
|
+
button.state-pending::after { content: ''; display: inline-block; width: 5px; height: 5px; margin-left: 7px; border-radius: 999px; background: currentColor; vertical-align: middle; animation: action-dot 1s ease-in-out infinite; }
|
|
36
|
+
button.state-active { animation: action-pulse 1.6s ease-in-out infinite; box-shadow: 0 0 0 1px rgba(96,165,250,.18), 0 0 18px rgba(37,99,235,.16); }
|
|
37
|
+
button.state-done { opacity: .58; filter: saturate(.75); }
|
|
38
|
+
button.state-retry { background: var(--orange); }
|
|
39
|
+
@keyframes action-pulse { 0%, 100% { filter: brightness(1); transform: translateY(0); } 50% { filter: brightness(1.12); transform: translateY(-1px); } }
|
|
40
|
+
@keyframes action-dot { 0%, 100% { opacity: .35; transform: scale(.75); } 50% { opacity: 1; transform: scale(1.15); } }
|
|
41
|
+
@media (prefers-reduced-motion: reduce) { button, button.state-active, button.state-pending::after, .refresh-pulse-chip.pulse .refresh-pulse-dot { animation: none !important; transition: none !important; } }
|
|
33
42
|
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
34
43
|
th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
|
|
35
44
|
th:nth-child(1), td:nth-child(1) { width: 34%; }
|
|
@@ -117,6 +126,9 @@
|
|
|
117
126
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
118
127
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
119
128
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
129
|
+
.codex-status { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
|
130
|
+
.codex-status.warning { color: #fbbf24; }
|
|
131
|
+
.codex-status code { color: #bfdbfe; }
|
|
120
132
|
.file-content-wrap { position: relative; }
|
|
121
133
|
.floating-copy-btn { position: absolute; top: 8px; left: 8px; z-index: 2; opacity: 0; pointer-events: none; transition: opacity .15s; padding: 5px 8px; background: rgba(71,85,105,.92); }
|
|
122
134
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
@@ -170,14 +182,7 @@
|
|
|
170
182
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
171
183
|
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
172
184
|
<div id="runNotice" class="notice warning hidden"></div>
|
|
173
|
-
<div class="toolbar">
|
|
174
|
-
<button onclick="planRun()">拆分任务</button>
|
|
175
|
-
<button onclick="dispatchRun()">派发执行</button>
|
|
176
|
-
<button onclick="judgeRun()">汇总验收</button>
|
|
177
|
-
<button class="secondary" onclick="refreshSelected()">刷新状态</button>
|
|
178
|
-
<button class="secondary" onclick="stopSelectedRun()">停止</button>
|
|
179
|
-
<button class="danger" onclick="archiveSelectedRun()">归档</button>
|
|
180
|
-
</div>
|
|
185
|
+
<div id="actionToolbar" class="toolbar"></div>
|
|
181
186
|
</div>
|
|
182
187
|
<h3>任务说明</h3>
|
|
183
188
|
<pre id="taskDescription" class="task-text">未选择任务批次</pre>
|
|
@@ -199,7 +204,7 @@
|
|
|
199
204
|
</section>
|
|
200
205
|
</div>
|
|
201
206
|
</main>
|
|
202
|
-
<footer id="pageFooter" class="
|
|
207
|
+
<footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
|
|
203
208
|
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
204
209
|
<div class="modal-card">
|
|
205
210
|
<h2>手动标记成功</h2>
|
|
@@ -219,6 +224,7 @@ let selectedTask = null;
|
|
|
219
224
|
let selectedFileName = null;
|
|
220
225
|
let manualCompleteTaskId = null;
|
|
221
226
|
let pendingArchiveRunId = null;
|
|
227
|
+
let pendingAction = null;
|
|
222
228
|
let currentState = null;
|
|
223
229
|
let lastAutoRefreshAt = null;
|
|
224
230
|
let runListVisibleCount = 10;
|
|
@@ -356,6 +362,25 @@ async function loadHealth() {
|
|
|
356
362
|
renderWorkspaceFilterOptions();
|
|
357
363
|
updateWorkspaceFilterTitle();
|
|
358
364
|
}
|
|
365
|
+
async function loadCodexStatus() {
|
|
366
|
+
const el = document.getElementById('codexStatus');
|
|
367
|
+
if (!el) return;
|
|
368
|
+
try {
|
|
369
|
+
const data = await api('/api/codex');
|
|
370
|
+
const codex = data.codex || {};
|
|
371
|
+
el.classList.remove('hidden', 'warning');
|
|
372
|
+
if (!codex.installed) {
|
|
373
|
+
el.classList.add('warning');
|
|
374
|
+
el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
|
|
375
|
+
} else {
|
|
376
|
+
el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
|
|
377
|
+
}
|
|
378
|
+
} catch (error) {
|
|
379
|
+
el.classList.remove('hidden');
|
|
380
|
+
el.classList.add('warning');
|
|
381
|
+
el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
359
384
|
function showCreateForm() {
|
|
360
385
|
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
361
386
|
clearFileView();
|
|
@@ -486,6 +511,7 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
486
511
|
statusByRunId.set(selectedRun, currentState);
|
|
487
512
|
if (auto) lastAutoRefreshAt = new Date();
|
|
488
513
|
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
514
|
+
renderActionToolbar();
|
|
489
515
|
if (auto) requestAnimationFrame(triggerRefreshPulse);
|
|
490
516
|
updateAutoRefreshHint();
|
|
491
517
|
updateRunNotice();
|
|
@@ -531,6 +557,97 @@ function refreshPulseChip() {
|
|
|
531
557
|
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
532
558
|
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
533
559
|
}
|
|
560
|
+
function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
|
|
561
|
+
const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
|
|
562
|
+
return `<button${classes ? ` class="${classes}"` : ''}${disabled ? ' disabled' : ''}${title ? ` title="${esc(title)}"` : ''} onclick="${onclick}">${esc(label)}</button>`;
|
|
563
|
+
}
|
|
564
|
+
function runActionState(key) {
|
|
565
|
+
if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
|
|
566
|
+
if (pendingAction === key) {
|
|
567
|
+
return {
|
|
568
|
+
plan: { label: '拆分中…', disabled: true, state: 'pending' },
|
|
569
|
+
dispatch: { label: '启动中…', disabled: true, state: 'pending' },
|
|
570
|
+
judge: { label: '验收中…', disabled: true, state: 'pending' },
|
|
571
|
+
stop: { label: '停止中…', disabled: true, state: 'pending' },
|
|
572
|
+
archive: { label: '归档中…', disabled: true, state: 'pending' }
|
|
573
|
+
}[key];
|
|
574
|
+
}
|
|
575
|
+
const status = currentState.status;
|
|
576
|
+
const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
|
|
577
|
+
if (key === 'plan') {
|
|
578
|
+
if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
|
|
579
|
+
if (status === 'planned' || status === 'running' || status === 'workers_completed' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
|
|
580
|
+
if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
|
|
581
|
+
return { label: '拆分', disabled: false, state: '' };
|
|
582
|
+
}
|
|
583
|
+
if (key === 'dispatch') {
|
|
584
|
+
if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
|
|
585
|
+
if (status === 'planned' || status === 'batch_blocked') return { label: '执行', disabled: false, state: status === 'batch_blocked' ? 'retry' : '' };
|
|
586
|
+
if (status === 'workers_failed') return { label: '重试执行', disabled: false, state: 'retry' };
|
|
587
|
+
if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
588
|
+
return { label: '执行', disabled: true, state: 'done' };
|
|
589
|
+
}
|
|
590
|
+
if (key === 'judge') {
|
|
591
|
+
if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
|
|
592
|
+
if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
|
|
593
|
+
if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
|
|
594
|
+
if (status === 'batches_completed' || status === 'workers_completed') return { label: '验收', disabled: false, state: '' };
|
|
595
|
+
return { label: '验收', disabled: true, state: 'done' };
|
|
596
|
+
}
|
|
597
|
+
if (key === 'stop') {
|
|
598
|
+
if (status === 'stopped') return { label: '已停止', disabled: true, state: 'done' };
|
|
599
|
+
const stoppable = ['planning','running','judging','planned','batch_blocked'].includes(status) || anyWorkerRunning;
|
|
600
|
+
return { label: '停止', disabled: !stoppable, state: stoppable ? '' : 'done' };
|
|
601
|
+
}
|
|
602
|
+
if (key === 'archive') return { label: '归档', disabled: false, state: '' };
|
|
603
|
+
return { label: key, disabled: false, state: '' };
|
|
604
|
+
}
|
|
605
|
+
function renderActionToolbar() {
|
|
606
|
+
const el = document.getElementById('actionToolbar');
|
|
607
|
+
if (!el) return;
|
|
608
|
+
if (!selectedRun || !currentState) {
|
|
609
|
+
el.innerHTML = actionButton({ key: 'refresh', label: '刷新状态', onclick: 'refreshSelected()', variant: 'secondary', disabled: true });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const plan = runActionState('plan');
|
|
613
|
+
const dispatch = runActionState('dispatch');
|
|
614
|
+
const judge = runActionState('judge');
|
|
615
|
+
const stop = runActionState('stop');
|
|
616
|
+
const archive = runActionState('archive');
|
|
617
|
+
el.innerHTML = [
|
|
618
|
+
actionButton({ key: 'plan', label: plan.label, onclick: 'planRun()', state: plan.state, disabled: plan.disabled, title: '拆分任务' }),
|
|
619
|
+
actionButton({ key: 'dispatch', label: dispatch.label, onclick: 'dispatchRun()', state: dispatch.state, disabled: dispatch.disabled, title: '派发执行' }),
|
|
620
|
+
actionButton({ key: 'judge', label: judge.label, onclick: 'judgeRun()', state: judge.state, disabled: judge.disabled, title: '汇总验收' }),
|
|
621
|
+
actionButton({ key: 'refresh', label: '刷新', onclick: 'refreshSelected()', variant: 'secondary' }),
|
|
622
|
+
actionButton({ key: 'stop', label: stop.label, onclick: 'stopSelectedRun()', variant: 'secondary', state: stop.state, disabled: stop.disabled, title: '停止当前批次' }),
|
|
623
|
+
actionButton({ key: 'archive', label: archive.label, onclick: 'archiveSelectedRun()', variant: 'danger', state: archive.state, disabled: archive.disabled, title: '归档当前批次' })
|
|
624
|
+
].join('');
|
|
625
|
+
}
|
|
626
|
+
async function runAction(fn) {
|
|
627
|
+
try { await fn(); }
|
|
628
|
+
catch (error) {
|
|
629
|
+
console.error('操作失败', error);
|
|
630
|
+
alert(userFacingErrorMessage(error));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function renderPendingActionState() {
|
|
634
|
+
renderActionToolbar();
|
|
635
|
+
if (currentState) renderTasks();
|
|
636
|
+
}
|
|
637
|
+
async function runActionWithPending(actionKey, fn) {
|
|
638
|
+
if (pendingAction === actionKey) return;
|
|
639
|
+
pendingAction = actionKey;
|
|
640
|
+
renderPendingActionState();
|
|
641
|
+
try {
|
|
642
|
+
await fn();
|
|
643
|
+
} catch (error) {
|
|
644
|
+
console.error('操作失败', error);
|
|
645
|
+
alert(userFacingErrorMessage(error));
|
|
646
|
+
} finally {
|
|
647
|
+
pendingAction = null;
|
|
648
|
+
renderPendingActionState();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
534
651
|
function triggerRefreshPulse() {
|
|
535
652
|
const el = document.getElementById('refreshPulse');
|
|
536
653
|
if (!el) return;
|
|
@@ -579,7 +696,8 @@ function taskActionCell(id, t) {
|
|
|
579
696
|
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
580
697
|
if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
|
|
581
698
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
582
|
-
|
|
699
|
+
const pending = pendingAction === `manual:${id}`;
|
|
700
|
+
return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中…' : '手动标记成功'}</button>`;
|
|
583
701
|
}
|
|
584
702
|
function shortSessionId(thread) {
|
|
585
703
|
const text = String(thread || '');
|
|
@@ -905,25 +1023,20 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
905
1023
|
else await refreshRuns();
|
|
906
1024
|
});
|
|
907
1025
|
}
|
|
908
|
-
async function planRun() { if (selectedRun) await
|
|
909
|
-
async function dispatchRun() { if (selectedRun) await
|
|
910
|
-
async function judgeRun() { if (selectedRun) await
|
|
911
|
-
async function runAction(fn) {
|
|
912
|
-
try { await fn(); }
|
|
913
|
-
catch (error) {
|
|
914
|
-
console.error('操作失败', error);
|
|
915
|
-
alert(userFacingErrorMessage(error));
|
|
916
|
-
}
|
|
917
|
-
}
|
|
1026
|
+
async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
1027
|
+
async function dispatchRun() { if (selectedRun) await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
|
|
1028
|
+
async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
918
1029
|
async function stopSelectedRun() {
|
|
919
1030
|
if (!selectedRun) return;
|
|
920
1031
|
const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
|
|
921
1032
|
if (!ok) return;
|
|
922
|
-
await
|
|
923
|
-
|
|
924
|
-
|
|
1033
|
+
await runActionWithPending('stop', async () => {
|
|
1034
|
+
await api(`/api/runs/${selectedRun}/stop`, {
|
|
1035
|
+
method: 'POST',
|
|
1036
|
+
body: JSON.stringify({ reason: 'stopped from dashboard' })
|
|
1037
|
+
});
|
|
1038
|
+
await refreshSelected();
|
|
925
1039
|
});
|
|
926
|
-
await refreshSelected();
|
|
927
1040
|
}
|
|
928
1041
|
function clearArchiveConfirm(runId) {
|
|
929
1042
|
if (pendingArchiveRunId !== runId) return;
|
|
@@ -946,7 +1059,7 @@ async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
|
946
1059
|
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
947
1060
|
if (!ok) return;
|
|
948
1061
|
}
|
|
949
|
-
await
|
|
1062
|
+
await runActionWithPending('archive', async () => {
|
|
950
1063
|
await api(`/api/runs/${runId}/archive`, {
|
|
951
1064
|
method: 'POST',
|
|
952
1065
|
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
@@ -986,17 +1099,21 @@ async function submitManualComplete() {
|
|
|
986
1099
|
if (!selectedRun || !taskId) return;
|
|
987
1100
|
const resultText = document.getElementById('manualCompleteResult').value.trim();
|
|
988
1101
|
if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
|
|
989
|
-
await
|
|
990
|
-
|
|
991
|
-
|
|
1102
|
+
await runActionWithPending(`manual:${taskId}`, async () => {
|
|
1103
|
+
await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
|
|
1104
|
+
method: 'POST',
|
|
1105
|
+
body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
|
|
1106
|
+
});
|
|
1107
|
+
closeManualCompleteModal();
|
|
1108
|
+
selectedTask = taskId;
|
|
1109
|
+
await refreshSelected();
|
|
1110
|
+
await loadFile('result.json');
|
|
992
1111
|
});
|
|
993
|
-
closeManualCompleteModal();
|
|
994
|
-
selectedTask = taskId;
|
|
995
|
-
await refreshSelected();
|
|
996
|
-
await loadFile('result.json');
|
|
997
1112
|
}
|
|
998
1113
|
|
|
999
1114
|
initializeWorkerSandboxPreference();
|
|
1115
|
+
renderActionToolbar();
|
|
1116
|
+
loadCodexStatus().catch(console.error);
|
|
1000
1117
|
loadHealth().then(refreshRuns);
|
|
1001
1118
|
setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
|
|
1002
1119
|
</script>
|
package/src/appServerClient.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import readline from 'node:readline';
|
|
3
3
|
import { CODEX_BIN } from './utils.js';
|
|
4
|
+
import { resolveCodexLauncher } from './codexLauncher.js';
|
|
4
5
|
|
|
5
6
|
export class CodexAppServerClient {
|
|
6
7
|
constructor() {
|
|
@@ -9,20 +10,49 @@ export class CodexAppServerClient {
|
|
|
9
10
|
this.pending = new Map();
|
|
10
11
|
this.initialized = false;
|
|
11
12
|
this.stderrTail = [];
|
|
13
|
+
this.rl = null;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
start() {
|
|
15
|
-
if (this.proc) return;
|
|
16
|
-
|
|
17
|
+
if (this.proc) return this.proc;
|
|
18
|
+
const { command, argsPrefix } = resolveCodexLauncher(CODEX_BIN);
|
|
19
|
+
this.proc = spawn(command, [...argsPrefix, 'app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
20
|
+
const proc = this.proc;
|
|
17
21
|
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
22
|
+
this.rl = rl;
|
|
18
23
|
rl.on('line', line => this.#handleLine(line));
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
this
|
|
24
|
+
proc.stderr.on('data', d => this.#pushStderr(String(d)));
|
|
25
|
+
proc.on('error', error => {
|
|
26
|
+
this.#rejectPendingFor(proc, error);
|
|
27
|
+
this.#clearProcess(proc, rl);
|
|
28
|
+
});
|
|
29
|
+
proc.on('exit', code => {
|
|
30
|
+
this.#rejectPendingFor(proc, new Error(`app-server exited: ${code}`));
|
|
31
|
+
this.#clearProcess(proc, rl);
|
|
32
|
+
});
|
|
33
|
+
return proc;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#rejectPendingFor(proc, error) {
|
|
37
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
38
|
+
if (pending.proc !== proc) continue;
|
|
39
|
+
this.pending.delete(id);
|
|
40
|
+
pending.reject(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#rejectAllPending(error) {
|
|
45
|
+
for (const { reject } of this.pending.values()) reject(error);
|
|
46
|
+
this.pending.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#clearProcess(proc = this.proc, rl = this.rl) {
|
|
50
|
+
rl?.close();
|
|
51
|
+
if (this.rl === rl) this.rl = null;
|
|
52
|
+
if (this.proc === proc) {
|
|
23
53
|
this.proc = null;
|
|
24
54
|
this.initialized = false;
|
|
25
|
-
}
|
|
55
|
+
}
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
#pushStderr(s) {
|
|
@@ -42,17 +72,36 @@ export class CodexAppServerClient {
|
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
async request(method, params = null, timeoutMs = 15000) {
|
|
45
|
-
this.start();
|
|
75
|
+
const proc = this.start();
|
|
46
76
|
const id = this.nextId++;
|
|
47
77
|
const msg = { id, method };
|
|
48
78
|
if (params !== null) msg.params = params;
|
|
49
79
|
return await new Promise((resolve, reject) => {
|
|
50
|
-
|
|
80
|
+
let timer;
|
|
81
|
+
let pending;
|
|
82
|
+
const fail = error => {
|
|
83
|
+
if (this.pending.get(id) !== pending) return;
|
|
51
84
|
this.pending.delete(id);
|
|
52
|
-
reject(
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
pending.reject(error);
|
|
86
|
+
};
|
|
87
|
+
pending = {
|
|
88
|
+
proc,
|
|
89
|
+
resolve: v => { clearTimeout(timer); resolve(v); },
|
|
90
|
+
reject: e => { clearTimeout(timer); reject(e); }
|
|
91
|
+
};
|
|
92
|
+
timer = setTimeout(() => fail(new Error(`app-server request timeout: ${method}`)), timeoutMs);
|
|
93
|
+
this.pending.set(id, pending);
|
|
94
|
+
if (this.proc !== proc || !proc?.stdin?.writable || proc.stdin.destroyed) {
|
|
95
|
+
fail(new Error(`app-server unavailable: ${method}`));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
proc.stdin.write(JSON.stringify(msg) + '\n', error => {
|
|
100
|
+
if (error) fail(error);
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
fail(error);
|
|
104
|
+
}
|
|
56
105
|
});
|
|
57
106
|
}
|
|
58
107
|
|
|
@@ -79,8 +128,11 @@ export class CodexAppServerClient {
|
|
|
79
128
|
|
|
80
129
|
stop() {
|
|
81
130
|
if (!this.proc) return;
|
|
82
|
-
this.proc
|
|
83
|
-
|
|
131
|
+
const proc = this.proc;
|
|
132
|
+
const rl = this.rl;
|
|
133
|
+
this.proc.kill();
|
|
134
|
+
this.#rejectAllPending(new Error('app-server stopped'));
|
|
135
|
+
this.#clearProcess(proc, rl);
|
|
84
136
|
}
|
|
85
137
|
}
|
|
86
138
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MAX_SHIM_BYTES = 64 * 1024;
|
|
6
|
+
const WHERE_TIMEOUT_MS = 5000;
|
|
7
|
+
|
|
8
|
+
function existingPath(filePath) {
|
|
9
|
+
return fs.existsSync(filePath) ? filePath : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function codexJsCandidatesFromShim(shimPath) {
|
|
13
|
+
const dir = path.dirname(shimPath);
|
|
14
|
+
return [
|
|
15
|
+
path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
|
|
16
|
+
path.join(dir, '..', '@openai', 'codex', 'bin', 'codex.js')
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function codexJsFromShim(shimPath) {
|
|
21
|
+
for (const candidate of codexJsCandidatesFromShim(shimPath)) {
|
|
22
|
+
const found = existingPath(candidate);
|
|
23
|
+
if (found) return found;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readShimTarget(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
if (/codex\.js$/i.test(filePath)) return filePath;
|
|
31
|
+
const stat = fs.statSync(filePath);
|
|
32
|
+
if (!stat.isFile() || stat.size > MAX_SHIM_BYTES) return null;
|
|
33
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
34
|
+
const shimJs = codexJsFromShim(filePath);
|
|
35
|
+
if (shimJs && /(?:@openai[\\/]+codex|node_modules[\\/]+@openai[\\/]+codex)[\\/]+bin[\\/]+codex\.js/i.test(text)) return shimJs;
|
|
36
|
+
return null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasPathSeparator(value) {
|
|
43
|
+
return path.isAbsolute(value) || value.includes(path.sep) || value.includes('/') || value.includes('\\');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolvePathCandidate(value) {
|
|
47
|
+
if (/\.(?:c?js|mjs)$/i.test(value)) return { command: process.execPath, argsPrefix: [value] };
|
|
48
|
+
const shimJs = readShimTarget(value);
|
|
49
|
+
if (shimJs) return { command: process.execPath, argsPrefix: [shimJs] };
|
|
50
|
+
return { command: value, argsPrefix: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function whereCandidates(value) {
|
|
54
|
+
const where = spawnSync('where.exe', [value], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
timeout: WHERE_TIMEOUT_MS,
|
|
57
|
+
windowsHide: true
|
|
58
|
+
});
|
|
59
|
+
return String(where.stdout || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveFromPath(value) {
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
for (const candidate of whereCandidates(value)) {
|
|
65
|
+
if (seen.has(candidate)) continue;
|
|
66
|
+
seen.add(candidate);
|
|
67
|
+
const resolved = resolvePathCandidate(candidate);
|
|
68
|
+
if (resolved.command !== candidate || fs.existsSync(candidate)) return resolved;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveWindowsCodexLauncher(spec) {
|
|
74
|
+
const value = String(spec || '').trim() || 'codex';
|
|
75
|
+
if (hasPathSeparator(value)) return resolvePathCandidate(value);
|
|
76
|
+
|
|
77
|
+
const pathResolved = resolveFromPath(value);
|
|
78
|
+
if (pathResolved) return pathResolved;
|
|
79
|
+
|
|
80
|
+
return resolvePathCandidate(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveCodexLauncher(spec = 'codex') {
|
|
84
|
+
const value = String(spec || '').trim() || 'codex';
|
|
85
|
+
if (/\.(?:c?js|mjs)$/i.test(value)) return { command: process.execPath, argsPrefix: [value] };
|
|
86
|
+
if (process.platform === 'win32') return resolveWindowsCodexLauncher(value);
|
|
87
|
+
return { command: value, argsPrefix: [] };
|
|
88
|
+
}
|
package/src/orchestrator.js
CHANGED
|
@@ -1048,6 +1048,8 @@ async function retryTasksInState(state, taskIds = null, { auto = false, maxRetri
|
|
|
1048
1048
|
if (!tasksToRetry.length) return { retried: [], state };
|
|
1049
1049
|
for (const task of tasksToRetry) {
|
|
1050
1050
|
if (hasLiveRunnerProcess(state, task.id, task)) throw new Error(`task still has a live process: ${task.id}`);
|
|
1051
|
+
}
|
|
1052
|
+
for (const task of tasksToRetry) {
|
|
1051
1053
|
const batch = (state.batches || []).find(item => item.id === task.batchId);
|
|
1052
1054
|
task.retryReason = reason;
|
|
1053
1055
|
await rotateWorkerAttempt(state, task);
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { CODEX_BIN } from '../utils.js';
|
|
5
|
+
import { resolveCodexLauncher } from '../codexLauncher.js';
|
|
5
6
|
|
|
6
7
|
function processKey(runId, taskId) {
|
|
7
8
|
return `${runId}:${taskId}`;
|
|
@@ -47,18 +48,34 @@ export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
|
|
|
47
48
|
const last = path.join(outDir, 'last_message.md');
|
|
48
49
|
fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
|
|
49
50
|
const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
|
|
50
|
-
const
|
|
51
|
+
const { command, argsPrefix } = resolveCodexLauncher(codexBin);
|
|
52
|
+
const child = spawn(command, [...argsPrefix, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
53
|
captureEventsWithTimestamps(child.stdout, events, timedEvents);
|
|
52
54
|
child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
|
|
53
55
|
const key = processKey(runId, taskId);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const listeners = [];
|
|
57
|
+
let exited = false;
|
|
58
|
+
let exitCode = null;
|
|
59
|
+
const finish = code => {
|
|
60
|
+
if (exited) return;
|
|
61
|
+
exited = true;
|
|
62
|
+
exitCode = code;
|
|
56
63
|
try { fs.writeFileSync(path.join(outDir, 'exit_code'), String(code)); } catch {}
|
|
57
64
|
runningProcesses.delete(key);
|
|
65
|
+
for (const listener of listeners) listener(code);
|
|
66
|
+
};
|
|
67
|
+
runningProcesses.set(key, child);
|
|
68
|
+
child.on('error', error => {
|
|
69
|
+
try { fs.appendFileSync(stderr, `${error.message || String(error)}\n`); } catch {}
|
|
70
|
+
finish(error?.code === 'ENOENT' ? 127 : 1);
|
|
58
71
|
});
|
|
72
|
+
child.on('exit', code => finish(code));
|
|
59
73
|
return {
|
|
60
|
-
pid: child.pid,
|
|
61
|
-
onExit(listener) {
|
|
74
|
+
pid: child.pid ?? null,
|
|
75
|
+
onExit(listener) {
|
|
76
|
+
if (exited) listener(exitCode);
|
|
77
|
+
else listeners.push(listener);
|
|
78
|
+
},
|
|
62
79
|
stop(signal = 'TERM') { child.kill(signal); }
|
|
63
80
|
};
|
|
64
81
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readTextMaybe,
|
|
9
9
|
writeJsonAtomic
|
|
10
10
|
} from '../utils.js';
|
|
11
|
+
import { resolveCodexLauncher } from '../codexLauncher.js';
|
|
11
12
|
import {
|
|
12
13
|
DEFAULT_TMUX_BIN,
|
|
13
14
|
sanitizeTmuxSessionName,
|
|
@@ -44,6 +45,10 @@ function shellQuote(value) {
|
|
|
44
45
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
function bashArrayAssignment(name, values) {
|
|
49
|
+
return `${name}=(${values.map(value => shellQuote(value)).join(' ')})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
|
|
48
53
|
const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
|
|
49
54
|
const TIMESTAMP_BIN = path.join(BIN_DIR, 'input-kanban-timestamp-events.js');
|
|
@@ -55,11 +60,12 @@ function buildOverviewCommand(runStatePath) {
|
|
|
55
60
|
return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
function buildRunScript({
|
|
63
|
+
function buildRunScript({ codexCommand, codexArgsPrefix = [], formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
64
|
+
const codexLauncher = bashArrayAssignment('CODEX_LAUNCHER', [codexCommand, ...codexArgsPrefix]);
|
|
59
65
|
return `#!/usr/bin/env bash
|
|
60
66
|
set -u
|
|
61
67
|
|
|
62
|
-
|
|
68
|
+
${codexLauncher}
|
|
63
69
|
SANDBOX=${shellQuote(sandbox)}
|
|
64
70
|
CWD=${shellQuote(cwd)}
|
|
65
71
|
OUT_DIR=${shellQuote(outDir)}
|
|
@@ -78,7 +84,7 @@ EXIT_CODE="$OUT_DIR/exit_code"
|
|
|
78
84
|
cd "$CWD"
|
|
79
85
|
rm -f "$EXIT_CODE"
|
|
80
86
|
touch "$EVENTS" "$TIMED_EVENTS" "$STDERR_LOG"
|
|
81
|
-
"
|
|
87
|
+
"\${CODEX_LAUNCHER[@]}" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(node "$TIMESTAMP_BIN" "$EVENTS" "$TIMED_EVENTS" | node "$FORMATTER_BIN") 2> >(tee -a "$STDERR_LOG" >&2)
|
|
82
88
|
code=$?
|
|
83
89
|
printf '%s' "$code" > "$EXIT_CODE"
|
|
84
90
|
printf '\\nInput Kanban tmux task completed.\\n'
|
|
@@ -113,7 +119,8 @@ export function createTmuxRunner({
|
|
|
113
119
|
const startedAt = nowIso();
|
|
114
120
|
|
|
115
121
|
await fsp.writeFile(promptFile, prompt);
|
|
116
|
-
|
|
122
|
+
const { command: codexCommand, argsPrefix: codexArgsPrefix } = resolveCodexLauncher(codexBin);
|
|
123
|
+
await fsp.writeFile(runScript, buildRunScript({ codexCommand, codexArgsPrefix, sandbox, cwd, outDir, runId, taskId, role }));
|
|
117
124
|
await fsp.chmod(runScript, 0o755);
|
|
118
125
|
|
|
119
126
|
const metadata = {
|
package/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { CodexAppServerClient } from './appServerClient.js';
|
|
6
|
-
import { APP_ROOT, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR } from './utils.js';
|
|
6
|
+
import { APP_ROOT, CODEX_BIN, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR, detectCodexInfo } from './utils.js';
|
|
7
7
|
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun, retryRun } from './orchestrator.js';
|
|
8
8
|
import { startAutoScheduler } from './scheduler.js';
|
|
9
9
|
|
|
@@ -43,7 +43,10 @@ async function handleApi(req, res, url, appClient) {
|
|
|
43
43
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
44
44
|
try {
|
|
45
45
|
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
46
|
-
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER });
|
|
46
|
+
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER, codexBin: CODEX_BIN });
|
|
47
|
+
}
|
|
48
|
+
if (req.method === 'GET' && url.pathname === '/api/codex') {
|
|
49
|
+
return send(res, 200, { ok: true, codex: await detectCodexInfo() });
|
|
47
50
|
}
|
|
48
51
|
if (parts[1] === 'runs' && parts.length === 2) {
|
|
49
52
|
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
|
package/src/utils.js
CHANGED
|
@@ -3,9 +3,13 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
6
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { resolveCodexLauncher } from './codexLauncher.js';
|
|
7
10
|
|
|
8
11
|
const require = createRequire(import.meta.url);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
9
13
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
10
14
|
|
|
11
15
|
export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -14,6 +18,8 @@ export const DEFAULT_WORKSPACE = path.resolve(process.env.KANBAN_DEFAULT_WORKSPA
|
|
|
14
18
|
export const DEFAULT_REPO = DEFAULT_WORKSPACE;
|
|
15
19
|
export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
|
|
16
20
|
export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
|
|
21
|
+
export const CODEX_NPM_PACKAGE = '@openai/codex';
|
|
22
|
+
export const CODEX_CHECK_LATEST = process.env.KANBAN_CODEX_CHECK_LATEST === '1';
|
|
17
23
|
export const VALID_RUNNERS = ['headless', 'tmux'];
|
|
18
24
|
|
|
19
25
|
export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
|
|
@@ -44,6 +50,48 @@ export async function fileInfo(file) {
|
|
|
44
50
|
try { const st = await fsp.stat(file); return { exists: true, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime.toISOString() }; }
|
|
45
51
|
catch { return { exists: false }; }
|
|
46
52
|
}
|
|
53
|
+
|
|
54
|
+
function parseCodexVersion(output) {
|
|
55
|
+
const text = String(output || '').trim();
|
|
56
|
+
const match = text.match(/(\d+\.\d+\.\d+)/);
|
|
57
|
+
return match ? match[1] : text || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function detectCodexInfo(codexBin = CODEX_BIN, { checkLatest = CODEX_CHECK_LATEST } = {}) {
|
|
61
|
+
const info = {
|
|
62
|
+
command: codexBin,
|
|
63
|
+
packageName: CODEX_NPM_PACKAGE,
|
|
64
|
+
installCommand: `npm install -g ${CODEX_NPM_PACKAGE}`,
|
|
65
|
+
updateCommand: `npm install -g ${CODEX_NPM_PACKAGE}`,
|
|
66
|
+
installed: false,
|
|
67
|
+
installedVersion: null,
|
|
68
|
+
latestVersion: null,
|
|
69
|
+
updateAvailable: false,
|
|
70
|
+
versionText: '',
|
|
71
|
+
installHint: '',
|
|
72
|
+
latestCheckEnabled: !!checkLatest
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const { command, argsPrefix } = resolveCodexLauncher(codexBin);
|
|
76
|
+
const { stdout } = await execFileAsync(command, [...argsPrefix, '--version'], { timeout: 5000, windowsHide: true });
|
|
77
|
+
const text = String(stdout || '').trim();
|
|
78
|
+
info.installed = true;
|
|
79
|
+
info.versionText = text;
|
|
80
|
+
info.installedVersion = parseCodexVersion(text);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
info.installHint = error?.code === 'ENOENT' ? 'codex command not found' : (error?.message || String(error));
|
|
83
|
+
}
|
|
84
|
+
if (checkLatest) {
|
|
85
|
+
try {
|
|
86
|
+
const { stdout } = await execFileAsync('npm', ['view', CODEX_NPM_PACKAGE, 'version', '--json'], { timeout: 5000, windowsHide: true });
|
|
87
|
+
const parsed = JSON.parse(String(stdout || '').trim());
|
|
88
|
+
const latest = Array.isArray(parsed) ? parsed.at(-1) : parsed;
|
|
89
|
+
if (typeof latest === 'string' && latest.trim()) info.latestVersion = latest.trim();
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
info.updateAvailable = !!(info.installedVersion && info.latestVersion && info.installedVersion !== info.latestVersion);
|
|
93
|
+
return info;
|
|
94
|
+
}
|
|
47
95
|
export async function readTextMaybe(file, maxBytes=200000) {
|
|
48
96
|
try {
|
|
49
97
|
const st = await fsp.stat(file);
|