input-kanban 0.0.12 → 0.0.14
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 -1
- package/PROJECT_GUIDE.md +4 -3
- package/README.md +9 -1
- package/RELEASE_NOTES.md +36 -0
- package/bin/input-kanban.js +7 -3
- package/package.json +2 -2
- package/public/index.html +194 -52
- package/src/appServerClient.js +67 -15
- package/src/codexLauncher.js +88 -0
- package/src/orchestrator.js +27 -2
- 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/ENVIRONMENT.md
CHANGED
|
@@ -40,7 +40,7 @@ input-kanban \
|
|
|
40
40
|
## Notes
|
|
41
41
|
|
|
42
42
|
- `KANBAN_DEFAULT_WORKSPACE` / `--workspace` should point to the local directory where work should run; `KANBAN_DEFAULT_REPO` / `--repo` remain compatibility aliases.
|
|
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.
|
|
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. If a run was created with `--plan-approval` / plan approval gate enabled, the scheduler stops after planning until the user confirms the plan.
|
|
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
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.
|
package/PROJECT_GUIDE.md
CHANGED
|
@@ -121,6 +121,7 @@ Supported submit options:
|
|
|
121
121
|
--task-file <path|->
|
|
122
122
|
--max-parallel <n>
|
|
123
123
|
--worker-sandbox <read-only|workspace-write|danger-full-access>
|
|
124
|
+
--plan-approval
|
|
124
125
|
--runner <headless|tmux>
|
|
125
126
|
--runs-dir <path>
|
|
126
127
|
--auto
|
|
@@ -130,7 +131,7 @@ Supported submit options:
|
|
|
130
131
|
--poll-ms <ms>
|
|
131
132
|
```
|
|
132
133
|
|
|
133
|
-
`input-kanban submit` creates a run and starts the planner. Task content can come from `--task <text>` or `--task-file <path|->`; omitting `--workspace` uses the current working directory as the target workspace, and `--repo` remains a compatibility alias. Omitting `--label` derives the run label from the first non-empty task line. Auto mode is the default for submit: it keeps polling the run through the shared orchestrator auto-advance path, dispatches batches when the plan is ready, and starts the final judge once all batches complete. `--no-auto` keeps submit to create + plan only. `-d` / `--detach` starts a background supervisor process for the same auto loop and lets the submitting terminal return immediately. The Web server also starts a lightweight scheduler that uses this shared path, so serial batch advancement does not depend on an open browser tab. The submit output includes `input-kanban status <runId> --watch` for terminal-side observation. Because it writes to the same runs directory as the Web server, CLI-created runs are visible in the 8787 dashboard when both processes use the same `--runs-dir`.
|
|
134
|
+
`input-kanban submit` creates a run and starts the planner. Task content can come from `--task <text>` or `--task-file <path|->`; omitting `--workspace` uses the current working directory as the target workspace, and `--repo` remains a compatibility alias. Omitting `--label` derives the run label from the first non-empty task line. Auto mode is the default for submit: it keeps polling the run through the shared orchestrator auto-advance path, dispatches batches when the plan is ready, and starts the final judge once all batches complete. `--plan-approval` adds a durable Planner → Worker gate: auto advances through planning, then pauses at the completed plan until the user confirms it from the Web dashboard by clicking `开始执行`. `--no-auto` keeps submit to create + plan only for the current CLI process, but a running Web server scheduler can still advance the run unless a durable gate such as `--plan-approval` is configured. `-d` / `--detach` starts a background supervisor process for the same auto loop and lets the submitting terminal return immediately. The Web server also starts a lightweight scheduler that uses this shared path, so serial batch advancement does not depend on an open browser tab. The submit output includes `input-kanban status <runId> --watch` for terminal-side observation. Because it writes to the same runs directory as the Web server, CLI-created runs are visible in the 8787 dashboard when both processes use the same `--runs-dir`.
|
|
134
135
|
|
|
135
136
|
Default behavior:
|
|
136
137
|
|
|
@@ -160,8 +161,8 @@ Key points:
|
|
|
160
161
|
- `result` prefers `judge/verdict.json` and falls back to `judge/last_message.md`; `--copy` copies the result to the clipboard.
|
|
161
162
|
- `stop` requires an explicit `runId` and uses the same stop path as the Web dashboard.
|
|
162
163
|
- `retry` retries failed/unknown workers while preserving the failed attempt directory.
|
|
163
|
-
- `submit` defaults to auto mode: planner -> dispatch -> final judge, with one automatic retry for `batch_blocked` by default. `--no-auto` keeps create + plan only, and `-d/--detach` moves the auto loop to a background supervisor.
|
|
164
|
-
- The Web
|
|
164
|
+
- `submit` defaults to auto mode: planner -> dispatch -> final judge, with one automatic retry for `batch_blocked` by default. `--plan-approval` changes this to planner -> wait for plan confirmation -> dispatch -> final judge. `--no-auto` keeps create + plan only for the CLI process, and `-d/--detach` moves the auto loop to a background supervisor.
|
|
165
|
+
- The Web server scheduler follows the same shared auto behavior: after planning it auto-dispatches planned runs and auto-starts the final judge once all batches complete, unless a plan approval gate is required and still unapproved.
|
|
165
166
|
|
|
166
167
|
Example agent loop:
|
|
167
168
|
|
package/README.md
CHANGED
|
@@ -56,7 +56,15 @@ input-kanban submit --task "修复登录问题,并补充回归测试" --label
|
|
|
56
56
|
|
|
57
57
|
`submit` 默认会创建任务批次、发起拆分、自动派发所有批次,并在全部完成后自动发起最终验收。默认 workspace 是当前目录;如果不传 `--label`,任务批次名称会从任务内容自动生成。它使用同一个 runs 目录,所以只要 8787 Web 看板也使用相同的 `--runs-dir`,CLI 创建的任务会在 Web 界面里可见。
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
如果希望 Planner 拆分完成后先看一眼计划,再放行执行,可以加计划确认 Gate:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
input-kanban submit --task-file task.md --plan-approval
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
开启后,auto 会推进到 Planner 完成并停在“已拆分,待确认”;用户在 Web 上点一次“开始执行”即确认计划,随后 worker 批次和 final judge 继续自动推进。
|
|
66
|
+
|
|
67
|
+
`input-kanban serve` 会启动一个轻量后台 scheduler,持续刷新并推进未完成的 run:plan ready 后派发 batch、串行 batch 完成后启动下一批、全部 batch 完成后启动 final judge。CLI `submit --auto` / `input-kanban auto <runId>` 与 Web server 共用同一套 orchestrator 自动推进逻辑,因此任务推进不再依赖浏览器页面是否打开或刷新。若 run 配置了计划确认 Gate,auto/scheduler 会停在该 Gate,不会越过未确认的计划。
|
|
60
68
|
|
|
61
69
|
如果希望提交后立即返回,让任务在后台自动执行,可以加 `-d` / `--detach`:
|
|
62
70
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.0.14
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Add a durable Plan Approval Gate: runs can now pause after planning until the generated plan is manually confirmed.
|
|
8
|
+
- Add `input-kanban submit --plan-approval`, which lets auto advance through planning and then stop at the unapproved plan gate instead of dispatching workers immediately.
|
|
9
|
+
- Make `dispatchRun()` confirm the plan gate before starting workers, so Web `开始执行` means “approve this plan and continue execution.”
|
|
10
|
+
- Clarify auto semantics in docs: auto advances to completion, failure, or the first unapproved gate; `--no-auto` is not a durable scheduler gate.
|
|
11
|
+
- Update the Web create form wording to `计划生成后手动确认后执行` and show planned gated runs as `已拆分,待确认`.
|
|
12
|
+
- Rework the run detail header layout: keep title/status on the left, move `Run ID ⧉` and `tmux ⧉` copy tools to a lightweight right-side tool group, and keep long unreadable IDs out of metadata chips.
|
|
13
|
+
- Record the Agent Profile / Candidate / Reviewer design direction in the private KB while keeping the current executor Codex-only.
|
|
14
|
+
|
|
15
|
+
### Verification
|
|
16
|
+
|
|
17
|
+
- `npm run check` passed locally with 78 tests.
|
|
18
|
+
- `npm run check` passed on the remote Windows validation host `zhangxing_win` with 78 tests for the release-candidate working tree.
|
|
19
|
+
- Windows Web smoke confirmed the plan approval input, compact title copy tools, `/api/health`, and `/api/codex`.
|
|
20
|
+
|
|
21
|
+
## v0.0.13
|
|
22
|
+
|
|
23
|
+
### Highlights
|
|
24
|
+
|
|
25
|
+
- Harden Codex launching on Windows by resolving npm `codex.cmd` shims and explicit JavaScript launchers through a shared `resolveCodexLauncher()` adapter.
|
|
26
|
+
- Use the shared Codex launcher path from the app-server client, headless runner, tmux runner, and Web footer Codex detection.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
- Keep `batch_blocked` runs discoverable via `input-kanban runs --active`, so agent/CLI auto loops can continue recoverable work instead of hiding blocked batches.
|
|
30
|
+
- 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.
|
|
31
|
+
- Add Windows-focused regression coverage for Codex launcher resolution, app-server spawn failures, headless spawn failures, and tmux launcher quoting.
|
|
32
|
+
|
|
33
|
+
### Verification
|
|
34
|
+
|
|
35
|
+
- `npm run check` passed locally with 76 tests.
|
|
36
|
+
- `npm run check` passed on the remote Windows validation host `zhangxing_win` with 76 tests after installing `@openai/codex` CLI.
|
|
37
|
+
- Windows backend Codex detection returned `codex-cli 0.139.0` through `detectCodexInfo()`.
|
|
38
|
+
|
|
3
39
|
## v0.0.12
|
|
4
40
|
|
|
5
41
|
### Highlights
|
package/bin/input-kanban.js
CHANGED
|
@@ -171,7 +171,7 @@ function parseSubmitArgs(argv) {
|
|
|
171
171
|
const args = {
|
|
172
172
|
host: '127.0.0.1', port: 8787, workspace: undefined, repo: undefined, runsDir: undefined, codexBin: undefined,
|
|
173
173
|
runner: undefined, label: undefined, taskText: undefined, taskFile: undefined, maxParallel: 3,
|
|
174
|
-
workerSandbox: 'workspace-write', auto: true, detach: false, watch: true, json: false, pollMs: 3000, maxRetries: 1, help: false
|
|
174
|
+
workerSandbox: 'workspace-write', planApproval: false, auto: true, detach: false, watch: true, json: false, pollMs: 3000, maxRetries: 1, help: false
|
|
175
175
|
};
|
|
176
176
|
for (let i = 0; i < argv.length; i++) {
|
|
177
177
|
const arg = argv[i];
|
|
@@ -190,6 +190,7 @@ function parseSubmitArgs(argv) {
|
|
|
190
190
|
else if (arg === '--task-file') args.taskFile = next();
|
|
191
191
|
else if (arg === '--max-parallel') args.maxParallel = Number(next());
|
|
192
192
|
else if (arg === '--worker-sandbox') args.workerSandbox = validateSandbox(next(), '--worker-sandbox');
|
|
193
|
+
else if (arg === '--plan-approval') args.planApproval = true;
|
|
193
194
|
else if (arg === '--auto') { args.auto = true; args.watch = true; }
|
|
194
195
|
else if (arg === '--no-auto') { args.auto = false; args.watch = false; }
|
|
195
196
|
else if (arg === '--detach' || arg === '-d') args.detach = true;
|
|
@@ -290,6 +291,7 @@ Submit options:
|
|
|
290
291
|
--task-file <path> Read task description from file, use - for stdin
|
|
291
292
|
--max-parallel <n> Default max parallel workers, default 3
|
|
292
293
|
--worker-sandbox <mode> read-only, workspace-write, or danger-full-access
|
|
294
|
+
--plan-approval Pause after planning until the generated plan is confirmed
|
|
293
295
|
--runner <mode> Runner mode: headless or tmux
|
|
294
296
|
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
295
297
|
--auto Plan, dispatch all batches, judge, and watch, default for submit
|
|
@@ -318,6 +320,7 @@ Options:
|
|
|
318
320
|
--task-file <path> Read task description from file, use - for stdin
|
|
319
321
|
--max-parallel <n> Default max parallel workers, default 3
|
|
320
322
|
--worker-sandbox <mode> read-only, workspace-write, or danger-full-access
|
|
323
|
+
--plan-approval Pause after planning until the generated plan is confirmed
|
|
321
324
|
--runner <mode> Runner mode: headless or tmux
|
|
322
325
|
--runs-dir <path> Runtime runs directory shared with the Web UI
|
|
323
326
|
--auto Plan, dispatch all batches, judge, and watch, default for submit
|
|
@@ -509,7 +512,7 @@ function isFailureTerminal(state) {
|
|
|
509
512
|
function isActiveRunSummary(run) {
|
|
510
513
|
if (!run) return false;
|
|
511
514
|
if (Number(run.running) > 0) return true;
|
|
512
|
-
return !['judged', 'judge_failed', '
|
|
515
|
+
return !['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(run.status);
|
|
513
516
|
}
|
|
514
517
|
|
|
515
518
|
function hasRecoverableUnknownTask(state) {
|
|
@@ -735,7 +738,8 @@ async function submit(args) {
|
|
|
735
738
|
workspace: process.env.KANBAN_DEFAULT_WORKSPACE || process.env.KANBAN_DEFAULT_REPO,
|
|
736
739
|
repo: process.env.KANBAN_DEFAULT_REPO,
|
|
737
740
|
maxParallel: args.maxParallel,
|
|
738
|
-
workerSandbox: args.workerSandbox
|
|
741
|
+
workerSandbox: args.workerSandbox,
|
|
742
|
+
planApproval: args.planApproval
|
|
739
743
|
});
|
|
740
744
|
if (!args.json) {
|
|
741
745
|
console.log(`已创建任务批次: ${state.runId}`);
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "input-kanban",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
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%; }
|
|
@@ -82,8 +91,11 @@
|
|
|
82
91
|
.run-card-meta .meta-chip { padding: 4px 7px; font-size: 11px; }
|
|
83
92
|
.run-card-meta .meta-chip.long .meta-value { max-width: 248px; }
|
|
84
93
|
.build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
|
|
85
|
-
.build-title { display:
|
|
86
|
-
.build-
|
|
94
|
+
.build-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; font-size: 22px; font-weight: 900; }
|
|
95
|
+
.build-title-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
|
|
96
|
+
.build-title-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
97
|
+
.build-title-tools { display: inline-flex; align-items: center; gap: 6px; justify-self: end; }
|
|
98
|
+
.build-meta { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
|
87
99
|
.meta-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: #020617; color: #cbd5e1; font-size: 12px; line-height: 1.2; }
|
|
88
100
|
.meta-chip.danger { border-color: #f97316; color: #fed7aa; background: rgba(180,83,9,.14); }
|
|
89
101
|
.meta-label { color: var(--muted); font-weight: 800; white-space: nowrap; }
|
|
@@ -97,6 +109,9 @@
|
|
|
97
109
|
.log-panel { margin-top: 16px; }
|
|
98
110
|
.file-tabs button { font-size: 13px; }
|
|
99
111
|
.copy-btn { padding: 2px 6px; margin: 0 0 0 6px; border-radius: 6px; font-size: 12px; line-height: 1.2; background: var(--gray); vertical-align: middle; }
|
|
112
|
+
.title-copy-btn { display: inline-flex; align-items: center; gap: 5px; opacity: .78; font-size: 12px; padding: 5px 8px; margin: 0; }
|
|
113
|
+
.title-copy-btn:hover, .title-copy-btn:focus { opacity: 1; }
|
|
114
|
+
.title-copy-btn .icon-svg { width: 12px; height: 12px; }
|
|
100
115
|
.rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
|
101
116
|
.run-card:hover .rename-btn, .run-card:focus-within .rename-btn, .build-title:hover .rename-btn, .build-title:focus-within .rename-btn, .rename-btn:focus { opacity: 1; pointer-events: auto; }
|
|
102
117
|
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
@@ -117,6 +132,9 @@
|
|
|
117
132
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
118
133
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
119
134
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
135
|
+
.codex-status { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
|
136
|
+
.codex-status.warning { color: #fbbf24; }
|
|
137
|
+
.codex-status code { color: #bfdbfe; }
|
|
120
138
|
.file-content-wrap { position: relative; }
|
|
121
139
|
.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
140
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
@@ -157,6 +175,8 @@
|
|
|
157
175
|
<option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
|
|
158
176
|
</select>
|
|
159
177
|
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。</div>
|
|
178
|
+
<label><input id="planApproval" type="checkbox" style="width:auto; margin-right:6px;" />计划生成后手动确认后执行</label>
|
|
179
|
+
<div class="muted">开启后会停在“已拆分,待确认”;点击“开始执行”后继续自动执行到验收。</div>
|
|
160
180
|
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
161
181
|
<div class="toolbar">
|
|
162
182
|
<button onclick="createRun()">创建批次</button>
|
|
@@ -170,14 +190,7 @@
|
|
|
170
190
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
171
191
|
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
172
192
|
<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>
|
|
193
|
+
<div id="actionToolbar" class="toolbar"></div>
|
|
181
194
|
</div>
|
|
182
195
|
<h3>任务说明</h3>
|
|
183
196
|
<pre id="taskDescription" class="task-text">未选择任务批次</pre>
|
|
@@ -199,7 +212,7 @@
|
|
|
199
212
|
</section>
|
|
200
213
|
</div>
|
|
201
214
|
</main>
|
|
202
|
-
<footer id="pageFooter" class="
|
|
215
|
+
<footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
|
|
203
216
|
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
204
217
|
<div class="modal-card">
|
|
205
218
|
<h2>手动标记成功</h2>
|
|
@@ -219,6 +232,7 @@ let selectedTask = null;
|
|
|
219
232
|
let selectedFileName = null;
|
|
220
233
|
let manualCompleteTaskId = null;
|
|
221
234
|
let pendingArchiveRunId = null;
|
|
235
|
+
let pendingAction = null;
|
|
222
236
|
let currentState = null;
|
|
223
237
|
let lastAutoRefreshAt = null;
|
|
224
238
|
let runListVisibleCount = 10;
|
|
@@ -267,6 +281,9 @@ const roleText = { planner: '任务拆分', judge: '汇总验收' };
|
|
|
267
281
|
function displayStatus(s) { return statusText[s] || s || '-'; }
|
|
268
282
|
function displayRole(s) { return roleText[s] || s || '-'; }
|
|
269
283
|
function pill(s) { return `<span class="pill ${s || ''}">${displayStatus(s)}</span>`; }
|
|
284
|
+
function planApprovalPending(state = currentState) { return !!(state?.gates?.planApproval?.required && !state.gates.planApproval.approved); }
|
|
285
|
+
function runStatusLabel(state = currentState) { return planApprovalPending(state) ? '已拆分,待确认' : displayStatus(state?.status); }
|
|
286
|
+
function statusPill(state = currentState) { return `<span class="pill ${state?.status || ''}">${esc(runStatusLabel(state))}</span>`; }
|
|
270
287
|
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
271
288
|
function formatDateTime(s) {
|
|
272
289
|
if (!s) return '-';
|
|
@@ -308,6 +325,12 @@ function gitChip() { return '<span class="meta-chip" title="Git 工作区"><span
|
|
|
308
325
|
function editIcon() {
|
|
309
326
|
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 16.5V20h3.5L18.1 9.4l-3.5-3.5L4 16.5Z" fill="currentColor"/><path d="m16 4.5 3.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
310
327
|
}
|
|
328
|
+
function copyIcon() {
|
|
329
|
+
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M8 8h11v11H8V8Z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M5 16H4V4h12v1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
330
|
+
}
|
|
331
|
+
function titleCopyLabel(kind) {
|
|
332
|
+
return kind === 'tmux' ? `<span>tmux</span>${copyIcon()}` : `<span>Run ID</span>${copyIcon()}`;
|
|
333
|
+
}
|
|
311
334
|
function archiveIcon() {
|
|
312
335
|
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 5h16v4H4V5Z" fill="currentColor"/><path d="M6 10h12v9H6v-9Z" fill="currentColor" opacity=".72"/><path d="M9 13h6" stroke="#020617" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
313
336
|
}
|
|
@@ -356,6 +379,25 @@ async function loadHealth() {
|
|
|
356
379
|
renderWorkspaceFilterOptions();
|
|
357
380
|
updateWorkspaceFilterTitle();
|
|
358
381
|
}
|
|
382
|
+
async function loadCodexStatus() {
|
|
383
|
+
const el = document.getElementById('codexStatus');
|
|
384
|
+
if (!el) return;
|
|
385
|
+
try {
|
|
386
|
+
const data = await api('/api/codex');
|
|
387
|
+
const codex = data.codex || {};
|
|
388
|
+
el.classList.remove('hidden', 'warning');
|
|
389
|
+
if (!codex.installed) {
|
|
390
|
+
el.classList.add('warning');
|
|
391
|
+
el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
|
|
392
|
+
} else {
|
|
393
|
+
el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
|
|
394
|
+
}
|
|
395
|
+
} catch (error) {
|
|
396
|
+
el.classList.remove('hidden');
|
|
397
|
+
el.classList.add('warning');
|
|
398
|
+
el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
359
401
|
function showCreateForm() {
|
|
360
402
|
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
361
403
|
clearFileView();
|
|
@@ -382,7 +424,7 @@ function initializeWorkerSandboxPreference() {
|
|
|
382
424
|
}
|
|
383
425
|
async function createRun() {
|
|
384
426
|
saveWorkerSandboxPreference();
|
|
385
|
-
const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
|
|
427
|
+
const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, planApproval: planApproval.checked, taskText: taskText.value };
|
|
386
428
|
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
387
429
|
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
388
430
|
clearFileView();
|
|
@@ -419,7 +461,7 @@ function renderRunList() {
|
|
|
419
461
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
420
462
|
const cards = visibleRuns.map(r => `
|
|
421
463
|
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
|
|
422
|
-
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${
|
|
464
|
+
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${statusPill(r)}</span></div>
|
|
423
465
|
<div class="run-card-meta">
|
|
424
466
|
${metaChip('工作区', basenamePath(r.workspacePath || r.repo), { title: r.workspacePath || r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制工作区地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
|
|
425
467
|
${r.git?.isGit ? gitChip() : ''}
|
|
@@ -486,6 +528,7 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
486
528
|
statusByRunId.set(selectedRun, currentState);
|
|
487
529
|
if (auto) lastAutoRefreshAt = new Date();
|
|
488
530
|
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
531
|
+
renderActionToolbar();
|
|
489
532
|
if (auto) requestAnimationFrame(triggerRefreshPulse);
|
|
490
533
|
updateAutoRefreshHint();
|
|
491
534
|
updateRunNotice();
|
|
@@ -496,8 +539,11 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
496
539
|
function renderSelectedHeader() {
|
|
497
540
|
if (!currentState) return '<div class="muted">未选择任务批次</div>';
|
|
498
541
|
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
542
|
+
const titleActions = [`<button class="secondary copy-btn title-copy-btn" title="复制 Run ID:${esc(currentState.runId)}" data-copy-kind="run-id" onclick="copyRunId(event)">${titleCopyLabel('run-id')}</button>`];
|
|
543
|
+
if (currentState.runner === 'tmux' && hasRunTmuxMetadata(currentState)) {
|
|
544
|
+
titleActions.push(`<button class="secondary copy-btn title-copy-btn" title="复制 tmux attach 指令" data-copy-kind="tmux" onclick="copyTmuxRunCommand(event)">${titleCopyLabel('tmux')}</button>`);
|
|
545
|
+
}
|
|
499
546
|
const chips = [
|
|
500
|
-
metaChip('Run ID', currentState.runId, { long: true }),
|
|
501
547
|
metaChip('工作区', basenamePath(currentState.workspacePath || currentState.repo), {
|
|
502
548
|
title: currentState.workspacePath || currentState.repo,
|
|
503
549
|
long: true,
|
|
@@ -510,18 +556,8 @@ function renderSelectedHeader() {
|
|
|
510
556
|
if (currentState.git?.isGit || currentState.workspace?.git?.isGit) {
|
|
511
557
|
chips.push(gitChip());
|
|
512
558
|
}
|
|
513
|
-
if (currentState.runner === 'tmux') {
|
|
514
|
-
if (hasRunTmuxMetadata(currentState)) {
|
|
515
|
-
chips.push(metaChip('终端', tmuxSessionName(currentState), {
|
|
516
|
-
long: true,
|
|
517
|
-
extra: `<button class="secondary copy-btn" onclick="copyTmuxRunCommand(event)">复制tmux attach指令</button>`
|
|
518
|
-
}));
|
|
519
|
-
} else {
|
|
520
|
-
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
559
|
chips.push(refreshPulseChip());
|
|
524
|
-
return `<div class="build-title"><span>${esc(currentState.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, currentState.runId)">${editIcon()}</button>${
|
|
560
|
+
return `<div class="build-title"><div class="build-title-main"><span class="build-title-text">${esc(currentState.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, currentState.runId)">${editIcon()}</button>${statusPill(currentState)}</div><div class="build-title-tools">${titleActions.join('')}</div></div><div class="build-meta">${chips.join('')}</div>`;
|
|
525
561
|
}
|
|
526
562
|
async function loadTaskDescription() {
|
|
527
563
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
@@ -531,6 +567,98 @@ function refreshPulseChip() {
|
|
|
531
567
|
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
532
568
|
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
533
569
|
}
|
|
570
|
+
function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
|
|
571
|
+
const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
|
|
572
|
+
return `<button${classes ? ` class="${classes}"` : ''}${disabled ? ' disabled' : ''}${title ? ` title="${esc(title)}"` : ''} onclick="${onclick}">${esc(label)}</button>`;
|
|
573
|
+
}
|
|
574
|
+
function runActionState(key) {
|
|
575
|
+
if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
|
|
576
|
+
if (pendingAction === key) {
|
|
577
|
+
return {
|
|
578
|
+
plan: { label: '拆分中…', disabled: true, state: 'pending' },
|
|
579
|
+
dispatch: { label: '启动中…', disabled: true, state: 'pending' },
|
|
580
|
+
judge: { label: '验收中…', disabled: true, state: 'pending' },
|
|
581
|
+
stop: { label: '停止中…', disabled: true, state: 'pending' },
|
|
582
|
+
archive: { label: '归档中…', disabled: true, state: 'pending' }
|
|
583
|
+
}[key];
|
|
584
|
+
}
|
|
585
|
+
const status = currentState.status;
|
|
586
|
+
const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
|
|
587
|
+
if (key === 'plan') {
|
|
588
|
+
if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
|
|
589
|
+
if (status === 'planned' || status === 'running' || status === 'workers_completed' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
|
|
590
|
+
if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
|
|
591
|
+
return { label: '拆分', disabled: false, state: '' };
|
|
592
|
+
}
|
|
593
|
+
if (key === 'dispatch') {
|
|
594
|
+
if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
|
|
595
|
+
if (status === 'planned') return { label: planApprovalPending() ? '开始执行' : '执行', disabled: false, state: '' };
|
|
596
|
+
if (status === 'batch_blocked') return { label: '执行', disabled: false, state: 'retry' };
|
|
597
|
+
if (status === 'workers_failed') return { label: '重试执行', disabled: false, state: 'retry' };
|
|
598
|
+
if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
599
|
+
return { label: '执行', disabled: true, state: 'done' };
|
|
600
|
+
}
|
|
601
|
+
if (key === 'judge') {
|
|
602
|
+
if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
|
|
603
|
+
if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
|
|
604
|
+
if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
|
|
605
|
+
if (status === 'batches_completed' || status === 'workers_completed') return { label: '验收', disabled: false, state: '' };
|
|
606
|
+
return { label: '验收', disabled: true, state: 'done' };
|
|
607
|
+
}
|
|
608
|
+
if (key === 'stop') {
|
|
609
|
+
if (status === 'stopped') return { label: '已停止', disabled: true, state: 'done' };
|
|
610
|
+
const stoppable = ['planning','running','judging','planned','batch_blocked'].includes(status) || anyWorkerRunning;
|
|
611
|
+
return { label: '停止', disabled: !stoppable, state: stoppable ? '' : 'done' };
|
|
612
|
+
}
|
|
613
|
+
if (key === 'archive') return { label: '归档', disabled: false, state: '' };
|
|
614
|
+
return { label: key, disabled: false, state: '' };
|
|
615
|
+
}
|
|
616
|
+
function renderActionToolbar() {
|
|
617
|
+
const el = document.getElementById('actionToolbar');
|
|
618
|
+
if (!el) return;
|
|
619
|
+
if (!selectedRun || !currentState) {
|
|
620
|
+
el.innerHTML = actionButton({ key: 'refresh', label: '刷新状态', onclick: 'refreshSelected()', variant: 'secondary', disabled: true });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const plan = runActionState('plan');
|
|
624
|
+
const dispatch = runActionState('dispatch');
|
|
625
|
+
const judge = runActionState('judge');
|
|
626
|
+
const stop = runActionState('stop');
|
|
627
|
+
const archive = runActionState('archive');
|
|
628
|
+
el.innerHTML = [
|
|
629
|
+
actionButton({ key: 'plan', label: plan.label, onclick: 'planRun()', state: plan.state, disabled: plan.disabled, title: '拆分任务' }),
|
|
630
|
+
actionButton({ key: 'dispatch', label: dispatch.label, onclick: 'dispatchRun()', state: dispatch.state, disabled: dispatch.disabled, title: '派发执行' }),
|
|
631
|
+
actionButton({ key: 'judge', label: judge.label, onclick: 'judgeRun()', state: judge.state, disabled: judge.disabled, title: '汇总验收' }),
|
|
632
|
+
actionButton({ key: 'refresh', label: '刷新', onclick: 'refreshSelected()', variant: 'secondary' }),
|
|
633
|
+
actionButton({ key: 'stop', label: stop.label, onclick: 'stopSelectedRun()', variant: 'secondary', state: stop.state, disabled: stop.disabled, title: '停止当前批次' }),
|
|
634
|
+
actionButton({ key: 'archive', label: archive.label, onclick: 'archiveSelectedRun()', variant: 'danger', state: archive.state, disabled: archive.disabled, title: '归档当前批次' })
|
|
635
|
+
].join('');
|
|
636
|
+
}
|
|
637
|
+
async function runAction(fn) {
|
|
638
|
+
try { await fn(); }
|
|
639
|
+
catch (error) {
|
|
640
|
+
console.error('操作失败', error);
|
|
641
|
+
alert(userFacingErrorMessage(error));
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function renderPendingActionState() {
|
|
645
|
+
renderActionToolbar();
|
|
646
|
+
if (currentState) renderTasks();
|
|
647
|
+
}
|
|
648
|
+
async function runActionWithPending(actionKey, fn) {
|
|
649
|
+
if (pendingAction === actionKey) return;
|
|
650
|
+
pendingAction = actionKey;
|
|
651
|
+
renderPendingActionState();
|
|
652
|
+
try {
|
|
653
|
+
await fn();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
console.error('操作失败', error);
|
|
656
|
+
alert(userFacingErrorMessage(error));
|
|
657
|
+
} finally {
|
|
658
|
+
pendingAction = null;
|
|
659
|
+
renderPendingActionState();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
534
662
|
function triggerRefreshPulse() {
|
|
535
663
|
const el = document.getElementById('refreshPulse');
|
|
536
664
|
if (!el) return;
|
|
@@ -579,7 +707,8 @@ function taskActionCell(id, t) {
|
|
|
579
707
|
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
580
708
|
if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
|
|
581
709
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
582
|
-
|
|
710
|
+
const pending = pendingAction === `manual:${id}`;
|
|
711
|
+
return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中…' : '手动标记成功'}</button>`;
|
|
583
712
|
}
|
|
584
713
|
function shortSessionId(thread) {
|
|
585
714
|
const text = String(thread || '');
|
|
@@ -726,6 +855,19 @@ function hideExecutionSummary() {
|
|
|
726
855
|
el.classList.add('hidden');
|
|
727
856
|
el.innerHTML = '';
|
|
728
857
|
}
|
|
858
|
+
async function copyRunId(event) {
|
|
859
|
+
event.stopPropagation();
|
|
860
|
+
const runId = currentState?.runId || selectedRun || '';
|
|
861
|
+
if (!runId) return;
|
|
862
|
+
try {
|
|
863
|
+
await navigator.clipboard.writeText(runId);
|
|
864
|
+
const kind = event.currentTarget.dataset.copyKind || 'run-id';
|
|
865
|
+
event.currentTarget.textContent = '✓';
|
|
866
|
+
setTimeout(() => { event.currentTarget.innerHTML = titleCopyLabel(kind); }, 900);
|
|
867
|
+
} catch {
|
|
868
|
+
prompt('复制 Run ID', runId);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
729
871
|
async function copyRepoPath(event, repoPath = currentState?.workspacePath || currentState?.repo || '') {
|
|
730
872
|
event.stopPropagation();
|
|
731
873
|
if (!repoPath) return;
|
|
@@ -746,9 +888,10 @@ async function copyTmuxRunCommand(event) {
|
|
|
746
888
|
const command = runAttachCommand(currentState);
|
|
747
889
|
if (!command) return;
|
|
748
890
|
try {
|
|
891
|
+
const kind = event.currentTarget.dataset.copyKind || 'tmux';
|
|
749
892
|
await navigator.clipboard.writeText(command);
|
|
750
|
-
event.currentTarget.textContent = '
|
|
751
|
-
setTimeout(() => { event.currentTarget.
|
|
893
|
+
event.currentTarget.textContent = '✓';
|
|
894
|
+
setTimeout(() => { event.currentTarget.innerHTML = titleCopyLabel(kind); }, 900);
|
|
752
895
|
} catch {
|
|
753
896
|
prompt('复制 tmux attach 命令', command);
|
|
754
897
|
}
|
|
@@ -905,25 +1048,20 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
905
1048
|
else await refreshRuns();
|
|
906
1049
|
});
|
|
907
1050
|
}
|
|
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
|
-
}
|
|
1051
|
+
async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
1052
|
+
async function dispatchRun() { if (selectedRun) await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
|
|
1053
|
+
async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
918
1054
|
async function stopSelectedRun() {
|
|
919
1055
|
if (!selectedRun) return;
|
|
920
1056
|
const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
|
|
921
1057
|
if (!ok) return;
|
|
922
|
-
await
|
|
923
|
-
|
|
924
|
-
|
|
1058
|
+
await runActionWithPending('stop', async () => {
|
|
1059
|
+
await api(`/api/runs/${selectedRun}/stop`, {
|
|
1060
|
+
method: 'POST',
|
|
1061
|
+
body: JSON.stringify({ reason: 'stopped from dashboard' })
|
|
1062
|
+
});
|
|
1063
|
+
await refreshSelected();
|
|
925
1064
|
});
|
|
926
|
-
await refreshSelected();
|
|
927
1065
|
}
|
|
928
1066
|
function clearArchiveConfirm(runId) {
|
|
929
1067
|
if (pendingArchiveRunId !== runId) return;
|
|
@@ -946,7 +1084,7 @@ async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
|
946
1084
|
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
947
1085
|
if (!ok) return;
|
|
948
1086
|
}
|
|
949
|
-
await
|
|
1087
|
+
await runActionWithPending('archive', async () => {
|
|
950
1088
|
await api(`/api/runs/${runId}/archive`, {
|
|
951
1089
|
method: 'POST',
|
|
952
1090
|
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
@@ -986,17 +1124,21 @@ async function submitManualComplete() {
|
|
|
986
1124
|
if (!selectedRun || !taskId) return;
|
|
987
1125
|
const resultText = document.getElementById('manualCompleteResult').value.trim();
|
|
988
1126
|
if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
|
|
989
|
-
await
|
|
990
|
-
|
|
991
|
-
|
|
1127
|
+
await runActionWithPending(`manual:${taskId}`, async () => {
|
|
1128
|
+
await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
|
|
1129
|
+
method: 'POST',
|
|
1130
|
+
body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
|
|
1131
|
+
});
|
|
1132
|
+
closeManualCompleteModal();
|
|
1133
|
+
selectedTask = taskId;
|
|
1134
|
+
await refreshSelected();
|
|
1135
|
+
await loadFile('result.json');
|
|
992
1136
|
});
|
|
993
|
-
closeManualCompleteModal();
|
|
994
|
-
selectedTask = taskId;
|
|
995
|
-
await refreshSelected();
|
|
996
|
-
await loadFile('result.json');
|
|
997
1137
|
}
|
|
998
1138
|
|
|
999
1139
|
initializeWorkerSandboxPreference();
|
|
1140
|
+
renderActionToolbar();
|
|
1141
|
+
loadCodexStatus().catch(console.error);
|
|
1000
1142
|
loadHealth().then(refreshRuns);
|
|
1001
1143
|
setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
|
|
1002
1144
|
</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
|
@@ -220,8 +220,28 @@ function deriveRunLabel(label, taskText) {
|
|
|
220
220
|
return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
function normalizePlanApprovalGate(value = false) {
|
|
224
|
+
const required = !!value;
|
|
225
|
+
return { required, approved: !required, approvedAt: null, approvedBy: null };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function planApprovalGate(state) {
|
|
229
|
+
return state?.gates?.planApproval || { required: false, approved: true, approvedAt: null, approvedBy: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function requiresPlanApproval(state) {
|
|
233
|
+
const gate = planApprovalGate(state);
|
|
234
|
+
return !!gate.required && !gate.approved;
|
|
235
|
+
}
|
|
223
236
|
|
|
224
|
-
|
|
237
|
+
function approvePlanGate(state, approvedBy = 'local-user') {
|
|
238
|
+
const gate = planApprovalGate(state);
|
|
239
|
+
if (!gate.required || gate.approved) return false;
|
|
240
|
+
state.gates = { ...(state.gates || {}), planApproval: { ...gate, approved: true, approvedAt: nowIso(), approvedBy } };
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function createRun({ label = '', taskText = '', workspace = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write', planApproval = false, requiresPlanApproval = false } = {}) {
|
|
225
245
|
const resolvedWorkspace = await assertWorkspacePath(workspace || repo || DEFAULT_WORKSPACE);
|
|
226
246
|
const workspaceMeta = await detectWorkspaceMetadata(resolvedWorkspace);
|
|
227
247
|
const runLabel = deriveRunLabel(label, taskText);
|
|
@@ -243,6 +263,7 @@ export async function createRun({ label = '', taskText = '', workspace = '', rep
|
|
|
243
263
|
repo: resolvedWorkspace,
|
|
244
264
|
maxParallel: Number(maxParallel) || 3,
|
|
245
265
|
workerSandbox: normalizeSandbox(workerSandbox),
|
|
266
|
+
gates: { planApproval: normalizePlanApprovalGate(planApproval || requiresPlanApproval) },
|
|
246
267
|
runner: RUNNER,
|
|
247
268
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
248
269
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -580,6 +601,7 @@ export async function dispatchRun(runId) {
|
|
|
580
601
|
if (!state.tasks?.length) throw new Error('no tasks in plan');
|
|
581
602
|
if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
|
|
582
603
|
if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
|
|
604
|
+
approvePlanGate(state);
|
|
583
605
|
state.status = 'running';
|
|
584
606
|
await scheduleMoreWorkers(state);
|
|
585
607
|
recomputeRunStatus(state);
|
|
@@ -816,6 +838,7 @@ export async function autoAdvanceRun(runId, { appClient = null, startCreated = f
|
|
|
816
838
|
return await refreshRun(runId, appClient) || state;
|
|
817
839
|
}
|
|
818
840
|
if (state.status === 'planned') {
|
|
841
|
+
if (requiresPlanApproval(state)) return state;
|
|
819
842
|
try { state = await dispatchRun(runId); }
|
|
820
843
|
catch (error) { if (!/all batches completed|current batch is blocked/i.test(error.message || '')) throw error; }
|
|
821
844
|
return await refreshRun(runId, appClient) || state;
|
|
@@ -1048,6 +1071,8 @@ async function retryTasksInState(state, taskIds = null, { auto = false, maxRetri
|
|
|
1048
1071
|
if (!tasksToRetry.length) return { retried: [], state };
|
|
1049
1072
|
for (const task of tasksToRetry) {
|
|
1050
1073
|
if (hasLiveRunnerProcess(state, task.id, task)) throw new Error(`task still has a live process: ${task.id}`);
|
|
1074
|
+
}
|
|
1075
|
+
for (const task of tasksToRetry) {
|
|
1051
1076
|
const batch = (state.batches || []).find(item => item.id === task.batchId);
|
|
1052
1077
|
task.retryReason = reason;
|
|
1053
1078
|
await rotateWorkerAttempt(state, task);
|
|
@@ -1228,7 +1253,7 @@ export function summaryOfRun(s) {
|
|
|
1228
1253
|
const tasks = s.tasks || [];
|
|
1229
1254
|
const workspacePath = s.workspacePath || s.repo || '';
|
|
1230
1255
|
const git = s.git || s.workspace?.git || null;
|
|
1231
|
-
return { runId: s.runId, label: s.label, repo: s.repo || workspacePath, workspacePath, workspaceName: s.workspaceName || path.basename(workspacePath || ''), git, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
|
|
1256
|
+
return { runId: s.runId, label: s.label, repo: s.repo || workspacePath, workspacePath, workspaceName: s.workspaceName || path.basename(workspacePath || ''), git, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', gates: s.gates || {}, archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
|
|
1232
1257
|
}
|
|
1233
1258
|
|
|
1234
1259
|
export async function readRunTaskText(runId) {
|
|
@@ -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);
|