input-kanban 0.0.10 → 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/ENVIRONMENT.md +1 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/RELEASE_NOTES.md +46 -0
- package/bin/input-kanban.js +1 -1
- package/package.json +2 -2
- package/public/index.html +182 -42
- 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 +50 -1
package/ENVIRONMENT.md
CHANGED
|
@@ -43,6 +43,7 @@ input-kanban \
|
|
|
43
43
|
- `input-kanban serve` starts a lightweight background scheduler that uses the same orchestrator auto-advance path as CLI `submit --auto` / `input-kanban auto <runId>`. It advances planned runs, serial batches, final judge startup, and bounded automatic retries without relying on an open browser tab.
|
|
44
44
|
- `KANBAN_RUNNER` / `--runner tmux` runs Codex tasks inside tmux windows while keeping scheduling and status tracking in the Node.js orchestrator.
|
|
45
45
|
- `KANBAN_RUNNER=tmux` is optional. Use it when you want live terminal visibility into planner, worker, and final judge sessions.
|
|
46
|
+
- With `KANBAN_RUNNER=tmux`, stopping and restarting `input-kanban serve` does not interrupt already-running Codex sessions; tmux keeps them alive and the scheduler resumes after restart. Do not assume the same safety for `headless` runner child processes.
|
|
46
47
|
- tmux mode uses one session per run and one window for planner, each batch, and judge. Batch windows contain an overview pane plus worker panes.
|
|
47
48
|
- tmux role windows stay open after the Codex command exits. The runner writes `exit_code` before entering the keep-open shell so Node.js status refresh can continue to advance from filesystem state.
|
|
48
49
|
- The dashboard exposes the run-level `tmux attach-session` copy action after tmux metadata is available. File viewer panels do not repeat tmux terminal details.
|
package/README.en.md
CHANGED
|
@@ -117,6 +117,8 @@ Defaults:
|
|
|
117
117
|
|
|
118
118
|
tmux mode still leaves batch barriers, `maxParallel`, final judge sequencing, and `judge_input.json` generation in Node.js. Each role output directory gets `run.sh` and `tmux.json`; status continues to be driven by `events.jsonl`, `stderr.log`, `last_message.md`, `exit_code`, and existing artifact files. After a tmux role command finishes, it writes `exit_code` first and then keeps the window open for inspection; the user closes the window manually from tmux.
|
|
119
119
|
|
|
120
|
+
If you are using `--runner tmux`, stopping and restarting `input-kanban serve` does not interrupt Codex sessions that are already running; the tmux session keeps going, and the scheduler resumes orchestration after the server comes back. With the `headless` runner, do not assume that restarting the service is safe for in-flight child processes.
|
|
121
|
+
|
|
120
122
|
tmux mode is optional and intended for live terminal viewing of each Codex role. `codex exec` is currently non-interactive and does not normally show manual approval prompts; if you select `danger-full-access` when creating a run, you explicitly relax the worker sandbox and should only do so in a controlled test workspace.
|
|
121
123
|
|
|
122
124
|
After run-level tmux metadata is available, the dashboard shows `Copy tmux attach command`. The file viewer no longer repeats tmux terminal details; use the run detail header to copy the attach command and inspect the tmux session.
|
package/README.md
CHANGED
|
@@ -117,6 +117,8 @@ input-kanban --open
|
|
|
117
117
|
|
|
118
118
|
tmux 模式仍由 Node.js 负责 batch barrier、`maxParallel`、final judge 顺序和 `judge_input.json` 生成。每个角色输出目录会写入 `run.sh` 和 `tmux.json`,状态继续由 `events.jsonl`、`stderr.log`、`last_message.md`、`exit_code` 和既有 artifact 文件驱动。tmux 角色命令完成后会先写入 `exit_code`,再保留 window,方便查看现场;需要关闭时由用户在 tmux 里手动退出。
|
|
119
119
|
|
|
120
|
+
如果当前使用的是 `--runner tmux`,中断并重新启动 `input-kanban serve` 不会中断正在执行中的 Codex 会话;tmux session 会继续运行,服务重启后 scheduler 会重新接管后续推进。若使用 `headless` runner,则不应假设服务重启对正在运行的子进程是安全的。
|
|
121
|
+
|
|
120
122
|
tmux 模式是可选能力,主要用于在终端里实时查看每个 Codex 角色的执行过程。`codex exec` 当前属于非交互模式,默认不会弹出人工 approval;如果创建任务时选择 `danger-full-access`,表示显式放开 worker sandbox 限制,应只在受控测试工作区中使用。
|
|
121
123
|
|
|
122
124
|
看板会在 run 生成 tmux 元数据后显示 `复制tmux attach指令`。文件查看区域不再重复展示 tmux 终端信息;如需查看现场,请从批次详情顶部复制 attach 指令进入 tmux session。
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
21
|
+
## v0.0.12
|
|
22
|
+
|
|
23
|
+
### Highlights
|
|
24
|
+
|
|
25
|
+
- Fix Windows startup/static serving by resolving `APP_ROOT` with `fileURLToPath(import.meta.url)` instead of URL pathname parsing.
|
|
26
|
+
- Add a regression test for serving `/` and `/api/health` from the HTTP server.
|
|
27
|
+
- Add task-detail hover guidance for sandbox and network capability issues, clarifying that sandbox-denied errors are not necessarily task failures.
|
|
28
|
+
- Remember the last selected Web worker sandbox mode in browser local storage, so users do not need to reselect `danger-full-access` or other modes each time.
|
|
29
|
+
- Auto-scroll the execution process view to the end when opened, while preserving the user's scroll position during refresh if they have scrolled upward.
|
|
30
|
+
|
|
31
|
+
### Verification
|
|
32
|
+
|
|
33
|
+
- `npm run check` passed with 64 tests.
|
|
34
|
+
- `npm pack --dry-run` passed before release prep.
|
|
35
|
+
|
|
36
|
+
## v0.0.11
|
|
37
|
+
|
|
38
|
+
### Highlights
|
|
39
|
+
|
|
40
|
+
- Simplify the Web sidebar header: show `任务批次` as the section title with a compact `新建` action on the right, removing repeated wording.
|
|
41
|
+
- Document safe `input-kanban serve` restarts for `tmux` runner: already-running Codex sessions in tmux continue while the server is down, and the scheduler resumes after restart.
|
|
42
|
+
- Clarify that `headless` runner does not provide the same safe-restart guarantee for in-flight child processes.
|
|
43
|
+
|
|
44
|
+
### Verification
|
|
45
|
+
|
|
46
|
+
- `npm run check` passed with 63 tests.
|
|
47
|
+
- `npm pack --dry-run` passed before release prep.
|
|
48
|
+
|
|
3
49
|
## v0.0.10
|
|
4
50
|
|
|
5
51
|
### 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%; }
|
|
@@ -60,12 +69,14 @@
|
|
|
60
69
|
.muted { color: var(--muted); font-size: 12px; }
|
|
61
70
|
.hidden { display: none; }
|
|
62
71
|
.toolbar { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
72
|
+
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 10px 0 8px; }
|
|
73
|
+
.section-header h2 { margin: 0; }
|
|
63
74
|
.workspace-filter-panel { margin: 4px 0 10px; display: flex; align-items: center; gap: 6px; }
|
|
64
75
|
.workspace-filter-select { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 7px 9px; color: #cbd5e1; }
|
|
65
76
|
.task-text { max-height: 180px; color: #cbd5e1; }
|
|
66
77
|
.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); }
|
|
78
|
+
.info-icon, .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; }
|
|
79
|
+
.info-icon:hover, .runs-load-icon:hover { opacity: 1; color: #cbd5e1; border-color: var(--line-strong); }
|
|
69
80
|
.run-list { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
|
|
70
81
|
.run-list-more { width: 100%; margin-top: 4px; }
|
|
71
82
|
.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; }
|
|
@@ -115,6 +126,9 @@
|
|
|
115
126
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
116
127
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
117
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; }
|
|
118
132
|
.file-content-wrap { position: relative; }
|
|
119
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); }
|
|
120
134
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
@@ -131,10 +145,10 @@
|
|
|
131
145
|
<main>
|
|
132
146
|
<div class="sidebar">
|
|
133
147
|
<section>
|
|
134
|
-
<div class="
|
|
135
|
-
<
|
|
148
|
+
<div class="section-header">
|
|
149
|
+
<h2>任务批次</h2>
|
|
150
|
+
<button class="secondary" onclick="showCreateForm()">新建</button>
|
|
136
151
|
</div>
|
|
137
|
-
<h2>任务批次</h2>
|
|
138
152
|
<div class="workspace-filter-panel">
|
|
139
153
|
<select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
|
|
140
154
|
<span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">ⓘ</span>
|
|
@@ -154,7 +168,7 @@
|
|
|
154
168
|
<option value="read-only">read-only(只读)</option>
|
|
155
169
|
<option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
|
|
156
170
|
</select>
|
|
157
|
-
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。</div>
|
|
171
|
+
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。</div>
|
|
158
172
|
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
159
173
|
<div class="toolbar">
|
|
160
174
|
<button onclick="createRun()">创建批次</button>
|
|
@@ -168,14 +182,7 @@
|
|
|
168
182
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
169
183
|
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
170
184
|
<div id="runNotice" class="notice warning hidden"></div>
|
|
171
|
-
<div class="toolbar">
|
|
172
|
-
<button onclick="planRun()">拆分任务</button>
|
|
173
|
-
<button onclick="dispatchRun()">派发执行</button>
|
|
174
|
-
<button onclick="judgeRun()">汇总验收</button>
|
|
175
|
-
<button class="secondary" onclick="refreshSelected()">刷新状态</button>
|
|
176
|
-
<button class="secondary" onclick="stopSelectedRun()">停止</button>
|
|
177
|
-
<button class="danger" onclick="archiveSelectedRun()">归档</button>
|
|
178
|
-
</div>
|
|
185
|
+
<div id="actionToolbar" class="toolbar"></div>
|
|
179
186
|
</div>
|
|
180
187
|
<h3>任务说明</h3>
|
|
181
188
|
<pre id="taskDescription" class="task-text">未选择任务批次</pre>
|
|
@@ -183,7 +190,10 @@
|
|
|
183
190
|
</section>
|
|
184
191
|
|
|
185
192
|
<section id="filePanel" class="log-panel">
|
|
186
|
-
<
|
|
193
|
+
<div class="section-header">
|
|
194
|
+
<h2>任务详情</h2>
|
|
195
|
+
<span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前 worker 沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。" aria-label="任务详情权限与网络提示">ⓘ</span>
|
|
196
|
+
</div>
|
|
187
197
|
<div id="fileTitle" class="muted">点击任务后查看详情</div>
|
|
188
198
|
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
189
199
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
@@ -194,7 +204,7 @@
|
|
|
194
204
|
</section>
|
|
195
205
|
</div>
|
|
196
206
|
</main>
|
|
197
|
-
<footer id="pageFooter" class="
|
|
207
|
+
<footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
|
|
198
208
|
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
199
209
|
<div class="modal-card">
|
|
200
210
|
<h2>手动标记成功</h2>
|
|
@@ -214,6 +224,7 @@ let selectedTask = null;
|
|
|
214
224
|
let selectedFileName = null;
|
|
215
225
|
let manualCompleteTaskId = null;
|
|
216
226
|
let pendingArchiveRunId = null;
|
|
227
|
+
let pendingAction = null;
|
|
217
228
|
let currentState = null;
|
|
218
229
|
let lastAutoRefreshAt = null;
|
|
219
230
|
let runListVisibleCount = 10;
|
|
@@ -223,6 +234,8 @@ const statusByRunId = new Map();
|
|
|
223
234
|
const AUTO_REFRESH_MS = 3000;
|
|
224
235
|
const RUN_LIST_PAGE_SIZE = 10;
|
|
225
236
|
const WORKSPACE_FILTER_ALL = '';
|
|
237
|
+
const WORKER_SANDBOX_STORAGE_KEY = 'input-kanban.workerSandbox';
|
|
238
|
+
const VALID_WORKER_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
226
239
|
let currentWorkspacePath = '';
|
|
227
240
|
let selectedWorkspaceFilter = localStorage.getItem('input-kanban.workspaceFilter') || WORKSPACE_FILTER_ALL;
|
|
228
241
|
|
|
@@ -349,6 +362,25 @@ async function loadHealth() {
|
|
|
349
362
|
renderWorkspaceFilterOptions();
|
|
350
363
|
updateWorkspaceFilterTitle();
|
|
351
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
|
+
}
|
|
352
384
|
function showCreateForm() {
|
|
353
385
|
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
354
386
|
clearFileView();
|
|
@@ -361,7 +393,20 @@ function hideCreateForm() {
|
|
|
361
393
|
document.getElementById('detailPanel').classList.remove('hidden');
|
|
362
394
|
document.getElementById('filePanel').classList.remove('hidden');
|
|
363
395
|
}
|
|
396
|
+
function saveWorkerSandboxPreference() {
|
|
397
|
+
const select = document.getElementById('workerSandbox');
|
|
398
|
+
const value = select?.value || '';
|
|
399
|
+
if (VALID_WORKER_SANDBOXES.has(value)) localStorage.setItem(WORKER_SANDBOX_STORAGE_KEY, value);
|
|
400
|
+
}
|
|
401
|
+
function initializeWorkerSandboxPreference() {
|
|
402
|
+
const select = document.getElementById('workerSandbox');
|
|
403
|
+
if (!select) return;
|
|
404
|
+
const saved = localStorage.getItem(WORKER_SANDBOX_STORAGE_KEY);
|
|
405
|
+
if (VALID_WORKER_SANDBOXES.has(saved)) select.value = saved;
|
|
406
|
+
select.addEventListener('change', saveWorkerSandboxPreference);
|
|
407
|
+
}
|
|
364
408
|
async function createRun() {
|
|
409
|
+
saveWorkerSandboxPreference();
|
|
365
410
|
const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
|
|
366
411
|
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
367
412
|
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
@@ -466,6 +511,7 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
466
511
|
statusByRunId.set(selectedRun, currentState);
|
|
467
512
|
if (auto) lastAutoRefreshAt = new Date();
|
|
468
513
|
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
514
|
+
renderActionToolbar();
|
|
469
515
|
if (auto) requestAnimationFrame(triggerRefreshPulse);
|
|
470
516
|
updateAutoRefreshHint();
|
|
471
517
|
updateRunNotice();
|
|
@@ -511,6 +557,97 @@ function refreshPulseChip() {
|
|
|
511
557
|
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
512
558
|
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
513
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
|
+
}
|
|
514
651
|
function triggerRefreshPulse() {
|
|
515
652
|
const el = document.getElementById('refreshPulse');
|
|
516
653
|
if (!el) return;
|
|
@@ -559,7 +696,8 @@ function taskActionCell(id, t) {
|
|
|
559
696
|
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
560
697
|
if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
|
|
561
698
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
562
|
-
|
|
699
|
+
const pending = pendingAction === `manual:${id}`;
|
|
700
|
+
return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中…' : '手动标记成功'}</button>`;
|
|
563
701
|
}
|
|
564
702
|
function shortSessionId(thread) {
|
|
565
703
|
const text = String(thread || '');
|
|
@@ -656,6 +794,7 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
656
794
|
selectedFileName = name;
|
|
657
795
|
const pre = document.getElementById('fileContent');
|
|
658
796
|
const previousScrollTop = pre.scrollTop;
|
|
797
|
+
const wasAtBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
|
|
659
798
|
let text;
|
|
660
799
|
const selected = taskById(selectedTask);
|
|
661
800
|
if (name === 'result.json' && selected?.manualCompletion?.hasManualResult) {
|
|
@@ -665,7 +804,8 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
665
804
|
text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
666
805
|
}
|
|
667
806
|
pre.textContent = text;
|
|
668
|
-
if (preserveScroll) pre.scrollTop =
|
|
807
|
+
if (name === 'events.pretty' && (!preserveScroll || wasAtBottom)) pre.scrollTop = pre.scrollHeight;
|
|
808
|
+
else if (preserveScroll) pre.scrollTop = previousScrollTop;
|
|
669
809
|
else pre.scrollTop = 0;
|
|
670
810
|
if (name === 'events.pretty') await renderExecutionSummary();
|
|
671
811
|
else hideExecutionSummary();
|
|
@@ -883,25 +1023,20 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
883
1023
|
else await refreshRuns();
|
|
884
1024
|
});
|
|
885
1025
|
}
|
|
886
|
-
async function planRun() { if (selectedRun) await
|
|
887
|
-
async function dispatchRun() { if (selectedRun) await
|
|
888
|
-
async function judgeRun() { if (selectedRun) await
|
|
889
|
-
async function runAction(fn) {
|
|
890
|
-
try { await fn(); }
|
|
891
|
-
catch (error) {
|
|
892
|
-
console.error('操作失败', error);
|
|
893
|
-
alert(userFacingErrorMessage(error));
|
|
894
|
-
}
|
|
895
|
-
}
|
|
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(); }); }
|
|
896
1029
|
async function stopSelectedRun() {
|
|
897
1030
|
if (!selectedRun) return;
|
|
898
1031
|
const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
|
|
899
1032
|
if (!ok) return;
|
|
900
|
-
await
|
|
901
|
-
|
|
902
|
-
|
|
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();
|
|
903
1039
|
});
|
|
904
|
-
await refreshSelected();
|
|
905
1040
|
}
|
|
906
1041
|
function clearArchiveConfirm(runId) {
|
|
907
1042
|
if (pendingArchiveRunId !== runId) return;
|
|
@@ -924,7 +1059,7 @@ async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
|
924
1059
|
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
925
1060
|
if (!ok) return;
|
|
926
1061
|
}
|
|
927
|
-
await
|
|
1062
|
+
await runActionWithPending('archive', async () => {
|
|
928
1063
|
await api(`/api/runs/${runId}/archive`, {
|
|
929
1064
|
method: 'POST',
|
|
930
1065
|
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
@@ -964,16 +1099,21 @@ async function submitManualComplete() {
|
|
|
964
1099
|
if (!selectedRun || !taskId) return;
|
|
965
1100
|
const resultText = document.getElementById('manualCompleteResult').value.trim();
|
|
966
1101
|
if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
|
|
967
|
-
await
|
|
968
|
-
|
|
969
|
-
|
|
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');
|
|
970
1111
|
});
|
|
971
|
-
closeManualCompleteModal();
|
|
972
|
-
selectedTask = taskId;
|
|
973
|
-
await refreshSelected();
|
|
974
|
-
await loadFile('result.json');
|
|
975
1112
|
}
|
|
976
1113
|
|
|
1114
|
+
initializeWorkerSandboxPreference();
|
|
1115
|
+
renderActionToolbar();
|
|
1116
|
+
loadCodexStatus().catch(console.error);
|
|
977
1117
|
loadHealth().then(refreshRuns);
|
|
978
1118
|
setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
|
|
979
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,16 +3,23 @@ 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';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { resolveCodexLauncher } from './codexLauncher.js';
|
|
6
10
|
|
|
7
11
|
const require = createRequire(import.meta.url);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
8
13
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
9
14
|
|
|
10
|
-
export const APP_ROOT = path.resolve(path.dirname(
|
|
15
|
+
export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
16
|
export { PACKAGE_VERSION };
|
|
12
17
|
export const DEFAULT_WORKSPACE = path.resolve(process.env.KANBAN_DEFAULT_WORKSPACE || process.env.KANBAN_DEFAULT_REPO || process.cwd());
|
|
13
18
|
export const DEFAULT_REPO = DEFAULT_WORKSPACE;
|
|
14
19
|
export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
|
|
15
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';
|
|
16
23
|
export const VALID_RUNNERS = ['headless', 'tmux'];
|
|
17
24
|
|
|
18
25
|
export function normalizeRunner(value = 'headless', source = 'KANBAN_RUNNER') {
|
|
@@ -43,6 +50,48 @@ export async function fileInfo(file) {
|
|
|
43
50
|
try { const st = await fsp.stat(file); return { exists: true, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime.toISOString() }; }
|
|
44
51
|
catch { return { exists: false }; }
|
|
45
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
|
+
}
|
|
46
95
|
export async function readTextMaybe(file, maxBytes=200000) {
|
|
47
96
|
try {
|
|
48
97
|
const st = await fsp.stat(file);
|