input-kanban 0.0.13 → 0.0.15
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 +6 -2
- package/package.json +1 -1
- package/public/index.html +60 -31
- package/src/orchestrator.js +59 -5
- package/src/server.js +10 -1
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.15
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Absorb PR #3 recovery hardening without directly merging its older `v0.0.13` branch, preserving the `v0.0.14` Plan Approval Gate and Web layout changes.
|
|
8
|
+
- Route blocked-run dashboard execution actions to `/api/runs/:id/retry`, so `batch_blocked` runs retry failed/unknown tasks instead of hitting the dispatch endpoint.
|
|
9
|
+
- Remove stale Web `workers_completed` / `workers_failed` UI state handling now that backend run status uses `batches_completed` and `batch_blocked`.
|
|
10
|
+
- Harden final judge starts: reject archived/stopped runs, duplicate running judges, and completed judges; failed judges are archived to `judge_attempts/` before retrying.
|
|
11
|
+
- Add short `/api/codex` detection caching to avoid repeatedly spawning Codex detection during frequent dashboard refreshes.
|
|
12
|
+
- Keep task-table Codex session IDs and their copy buttons on one line by widening the session column and using a compact inline layout.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- `npm run check` passed locally with 84 tests.
|
|
17
|
+
- `npm pack --dry-run` passed for `input-kanban@0.0.15`.
|
|
18
|
+
- Windows release-candidate validation on `zhangxing_win` passed with 84 tests.
|
|
19
|
+
- Windows Web smoke confirmed `/api/health`, `/api/codex`, Plan Approval UI, compact header copy tools, and one-line Codex session copy layout.
|
|
20
|
+
|
|
21
|
+
## v0.0.14
|
|
22
|
+
|
|
23
|
+
### Highlights
|
|
24
|
+
|
|
25
|
+
- Add a durable Plan Approval Gate: runs can now pause after planning until the generated plan is manually confirmed.
|
|
26
|
+
- 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.
|
|
27
|
+
- Make `dispatchRun()` confirm the plan gate before starting workers, so Web `开始执行` means “approve this plan and continue execution.”
|
|
28
|
+
- Clarify auto semantics in docs: auto advances to completion, failure, or the first unapproved gate; `--no-auto` is not a durable scheduler gate.
|
|
29
|
+
- Update the Web create form wording to `计划生成后手动确认后执行` and show planned gated runs as `已拆分,待确认`.
|
|
30
|
+
- 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.
|
|
31
|
+
- Record the Agent Profile / Candidate / Reviewer design direction in the private KB while keeping the current executor Codex-only.
|
|
32
|
+
|
|
33
|
+
### Verification
|
|
34
|
+
|
|
35
|
+
- `npm run check` passed locally with 78 tests.
|
|
36
|
+
- `npm run check` passed on the remote Windows validation host `zhangxing_win` with 78 tests for the release-candidate working tree.
|
|
37
|
+
- Windows Web smoke confirmed the plan approval input, compact title copy tools, `/api/health`, and `/api/codex`.
|
|
38
|
+
|
|
3
39
|
## v0.0.13
|
|
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
|
|
@@ -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
package/public/index.html
CHANGED
|
@@ -41,20 +41,20 @@
|
|
|
41
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; } }
|
|
42
42
|
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
43
43
|
th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
|
|
44
|
-
th:nth-child(1), td:nth-child(1) { width:
|
|
44
|
+
th:nth-child(1), td:nth-child(1) { width: 32%; }
|
|
45
45
|
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
46
46
|
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
47
47
|
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
48
48
|
th:nth-child(5), td:nth-child(5) { width: 96px; }
|
|
49
|
-
th:nth-child(6), td:nth-child(6) { width:
|
|
49
|
+
th:nth-child(6), td:nth-child(6) { width: 116px; }
|
|
50
50
|
th:nth-child(7), td:nth-child(7) { width: 66px; }
|
|
51
51
|
th:nth-child(8), td:nth-child(8) { width: 94px; }
|
|
52
52
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
53
53
|
tr:hover { background: #162033; cursor: pointer; }
|
|
54
54
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
55
|
-
.completed, .judged, .
|
|
55
|
+
.completed, .judged, .planned, .batches_completed { background: var(--green); }
|
|
56
56
|
.running, .planning, .judging { background: var(--blue); }
|
|
57
|
-
.failed, .
|
|
57
|
+
.failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
58
58
|
.plan_empty { background: var(--orange); }
|
|
59
59
|
.pending, .created { background: var(--gray); }
|
|
60
60
|
.batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
|
|
@@ -91,8 +91,11 @@
|
|
|
91
91
|
.run-card-meta .meta-chip { padding: 4px 7px; font-size: 11px; }
|
|
92
92
|
.run-card-meta .meta-chip.long .meta-value { max-width: 248px; }
|
|
93
93
|
.build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
|
|
94
|
-
.build-title { display:
|
|
95
|
-
.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; }
|
|
96
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; }
|
|
97
100
|
.meta-chip.danger { border-color: #f97316; color: #fed7aa; background: rgba(180,83,9,.14); }
|
|
98
101
|
.meta-label { color: var(--muted); font-weight: 800; white-space: nowrap; }
|
|
@@ -106,12 +109,17 @@
|
|
|
106
109
|
.log-panel { margin-top: 16px; }
|
|
107
110
|
.file-tabs button { font-size: 13px; }
|
|
108
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; }
|
|
109
115
|
.rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
|
110
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; }
|
|
111
117
|
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
112
118
|
.archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
|
|
113
119
|
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
114
|
-
.session-cell {
|
|
120
|
+
.session-cell-wrap { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
|
121
|
+
.session-cell { min-width: 0; font-variant-numeric: tabular-nums; }
|
|
122
|
+
.session-cell-wrap .copy-btn { flex: 0 0 auto; margin-left: 0; }
|
|
115
123
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
116
124
|
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
117
125
|
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
@@ -169,6 +177,8 @@
|
|
|
169
177
|
<option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
|
|
170
178
|
</select>
|
|
171
179
|
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。</div>
|
|
180
|
+
<label><input id="planApproval" type="checkbox" style="width:auto; margin-right:6px;" />计划生成后手动确认后执行</label>
|
|
181
|
+
<div class="muted">开启后会停在“已拆分,待确认”;点击“开始执行”后继续自动执行到验收。</div>
|
|
172
182
|
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
173
183
|
<div class="toolbar">
|
|
174
184
|
<button onclick="createRun()">创建批次</button>
|
|
@@ -265,7 +275,6 @@ function userFacingErrorMessage(error) {
|
|
|
265
275
|
const statusText = {
|
|
266
276
|
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
267
277
|
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
268
|
-
workers_completed: '子任务完成', workers_failed: '子任务失败',
|
|
269
278
|
batches_completed: '批次完成', batch_blocked: '批次阻塞', plan_empty: '拆分为空', stopped: '已停止',
|
|
270
279
|
judging: '验收中', judged: '已验收', plan_failed: '拆分失败', judge_failed: '验收失败'
|
|
271
280
|
};
|
|
@@ -273,6 +282,9 @@ const roleText = { planner: '任务拆分', judge: '汇总验收' };
|
|
|
273
282
|
function displayStatus(s) { return statusText[s] || s || '-'; }
|
|
274
283
|
function displayRole(s) { return roleText[s] || s || '-'; }
|
|
275
284
|
function pill(s) { return `<span class="pill ${s || ''}">${displayStatus(s)}</span>`; }
|
|
285
|
+
function planApprovalPending(state = currentState) { return !!(state?.gates?.planApproval?.required && !state.gates.planApproval.approved); }
|
|
286
|
+
function runStatusLabel(state = currentState) { return planApprovalPending(state) ? '已拆分,待确认' : displayStatus(state?.status); }
|
|
287
|
+
function statusPill(state = currentState) { return `<span class="pill ${state?.status || ''}">${esc(runStatusLabel(state))}</span>`; }
|
|
276
288
|
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
277
289
|
function formatDateTime(s) {
|
|
278
290
|
if (!s) return '-';
|
|
@@ -314,6 +326,12 @@ function gitChip() { return '<span class="meta-chip" title="Git 工作区"><span
|
|
|
314
326
|
function editIcon() {
|
|
315
327
|
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>';
|
|
316
328
|
}
|
|
329
|
+
function copyIcon() {
|
|
330
|
+
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>';
|
|
331
|
+
}
|
|
332
|
+
function titleCopyLabel(kind) {
|
|
333
|
+
return kind === 'tmux' ? `<span>tmux</span>${copyIcon()}` : `<span>Run ID</span>${copyIcon()}`;
|
|
334
|
+
}
|
|
317
335
|
function archiveIcon() {
|
|
318
336
|
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>';
|
|
319
337
|
}
|
|
@@ -407,7 +425,7 @@ function initializeWorkerSandboxPreference() {
|
|
|
407
425
|
}
|
|
408
426
|
async function createRun() {
|
|
409
427
|
saveWorkerSandboxPreference();
|
|
410
|
-
const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
|
|
428
|
+
const body = { label: label.value, workspace: repo.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, planApproval: planApproval.checked, taskText: taskText.value };
|
|
411
429
|
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
412
430
|
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
413
431
|
clearFileView();
|
|
@@ -444,7 +462,7 @@ function renderRunList() {
|
|
|
444
462
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
445
463
|
const cards = visibleRuns.map(r => `
|
|
446
464
|
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
|
|
447
|
-
<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>${
|
|
465
|
+
<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>
|
|
448
466
|
<div class="run-card-meta">
|
|
449
467
|
${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>` })}
|
|
450
468
|
${r.git?.isGit ? gitChip() : ''}
|
|
@@ -522,8 +540,11 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
522
540
|
function renderSelectedHeader() {
|
|
523
541
|
if (!currentState) return '<div class="muted">未选择任务批次</div>';
|
|
524
542
|
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
543
|
+
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>`];
|
|
544
|
+
if (currentState.runner === 'tmux' && hasRunTmuxMetadata(currentState)) {
|
|
545
|
+
titleActions.push(`<button class="secondary copy-btn title-copy-btn" title="复制 tmux attach 指令" data-copy-kind="tmux" onclick="copyTmuxRunCommand(event)">${titleCopyLabel('tmux')}</button>`);
|
|
546
|
+
}
|
|
525
547
|
const chips = [
|
|
526
|
-
metaChip('Run ID', currentState.runId, { long: true }),
|
|
527
548
|
metaChip('工作区', basenamePath(currentState.workspacePath || currentState.repo), {
|
|
528
549
|
title: currentState.workspacePath || currentState.repo,
|
|
529
550
|
long: true,
|
|
@@ -536,18 +557,8 @@ function renderSelectedHeader() {
|
|
|
536
557
|
if (currentState.git?.isGit || currentState.workspace?.git?.isGit) {
|
|
537
558
|
chips.push(gitChip());
|
|
538
559
|
}
|
|
539
|
-
if (currentState.runner === 'tmux') {
|
|
540
|
-
if (hasRunTmuxMetadata(currentState)) {
|
|
541
|
-
chips.push(metaChip('终端', tmuxSessionName(currentState), {
|
|
542
|
-
long: true,
|
|
543
|
-
extra: `<button class="secondary copy-btn" onclick="copyTmuxRunCommand(event)">复制tmux attach指令</button>`
|
|
544
|
-
}));
|
|
545
|
-
} else {
|
|
546
|
-
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
560
|
chips.push(refreshPulseChip());
|
|
550
|
-
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>${
|
|
561
|
+
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>`;
|
|
551
562
|
}
|
|
552
563
|
async function loadTaskDescription() {
|
|
553
564
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
@@ -576,22 +587,22 @@ function runActionState(key) {
|
|
|
576
587
|
const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
|
|
577
588
|
if (key === 'plan') {
|
|
578
589
|
if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
|
|
579
|
-
if (status === 'planned' || status === 'running' || status === '
|
|
590
|
+
if (status === 'planned' || status === 'running' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
|
|
580
591
|
if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
|
|
581
592
|
return { label: '拆分', disabled: false, state: '' };
|
|
582
593
|
}
|
|
583
594
|
if (key === 'dispatch') {
|
|
584
595
|
if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
|
|
585
|
-
if (status === 'planned'
|
|
586
|
-
if (status === '
|
|
587
|
-
if (['
|
|
596
|
+
if (status === 'planned') return { label: planApprovalPending() ? '开始执行' : '执行', disabled: false, state: '' };
|
|
597
|
+
if (status === 'batch_blocked') return { label: '重试执行', disabled: false, state: 'retry' };
|
|
598
|
+
if (['batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
588
599
|
return { label: '执行', disabled: true, state: 'done' };
|
|
589
600
|
}
|
|
590
601
|
if (key === 'judge') {
|
|
591
602
|
if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
|
|
592
603
|
if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
|
|
593
604
|
if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
|
|
594
|
-
if (status === 'batches_completed'
|
|
605
|
+
if (status === 'batches_completed') return { label: '验收', disabled: false, state: '' };
|
|
595
606
|
return { label: '验收', disabled: true, state: 'done' };
|
|
596
607
|
}
|
|
597
608
|
if (key === 'stop') {
|
|
@@ -705,7 +716,7 @@ function shortSessionId(thread) {
|
|
|
705
716
|
}
|
|
706
717
|
function sessionCell(thread) {
|
|
707
718
|
if (!thread) return '-';
|
|
708
|
-
return `<span class="session-cell" title="${esc(thread)}">…${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
|
|
719
|
+
return `<span class="session-cell-wrap"><span class="session-cell" title="${esc(thread)}">…${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button></span>`;
|
|
709
720
|
}
|
|
710
721
|
function taskStartedCell(t) {
|
|
711
722
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
@@ -844,6 +855,19 @@ function hideExecutionSummary() {
|
|
|
844
855
|
el.classList.add('hidden');
|
|
845
856
|
el.innerHTML = '';
|
|
846
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
|
+
}
|
|
847
871
|
async function copyRepoPath(event, repoPath = currentState?.workspacePath || currentState?.repo || '') {
|
|
848
872
|
event.stopPropagation();
|
|
849
873
|
if (!repoPath) return;
|
|
@@ -864,9 +888,10 @@ async function copyTmuxRunCommand(event) {
|
|
|
864
888
|
const command = runAttachCommand(currentState);
|
|
865
889
|
if (!command) return;
|
|
866
890
|
try {
|
|
891
|
+
const kind = event.currentTarget.dataset.copyKind || 'tmux';
|
|
867
892
|
await navigator.clipboard.writeText(command);
|
|
868
|
-
event.currentTarget.textContent = '
|
|
869
|
-
setTimeout(() => { event.currentTarget.
|
|
893
|
+
event.currentTarget.textContent = '✓';
|
|
894
|
+
setTimeout(() => { event.currentTarget.innerHTML = titleCopyLabel(kind); }, 900);
|
|
870
895
|
} catch {
|
|
871
896
|
prompt('复制 tmux attach 命令', command);
|
|
872
897
|
}
|
|
@@ -1024,7 +1049,11 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
1024
1049
|
});
|
|
1025
1050
|
}
|
|
1026
1051
|
async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
1027
|
-
async function dispatchRun() {
|
|
1052
|
+
async function dispatchRun() {
|
|
1053
|
+
if (!selectedRun) return;
|
|
1054
|
+
const endpoint = currentState?.status === 'batch_blocked' ? 'retry' : 'dispatch';
|
|
1055
|
+
await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/${endpoint}`, {method:'POST'}); await refreshSelected(); });
|
|
1056
|
+
}
|
|
1028
1057
|
async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
1029
1058
|
async function stopSelectedRun() {
|
|
1030
1059
|
if (!selectedRun) return;
|
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' }
|
|
@@ -479,6 +500,27 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
479
500
|
}];
|
|
480
501
|
}
|
|
481
502
|
|
|
503
|
+
async function rotateJudgeAttempt(state, runDir) {
|
|
504
|
+
const judgeDir = roleDir(runDir, 'judge');
|
|
505
|
+
if (!fs.existsSync(judgeDir)) return;
|
|
506
|
+
const attemptsDir = path.join(runDir, 'judge_attempts');
|
|
507
|
+
await ensureDir(attemptsDir);
|
|
508
|
+
const previousAttempts = (state.judgeAttempts || []).map(item => Number(item.attempt || 0)).filter(Number.isFinite);
|
|
509
|
+
const attempt = Number(state.judge?.attempt || 0) || Math.max(1, 1 + Math.max(0, ...previousAttempts));
|
|
510
|
+
const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
|
|
511
|
+
await fsp.rm(archivedDir, { recursive: true, force: true });
|
|
512
|
+
await fsp.rename(judgeDir, archivedDir);
|
|
513
|
+
state.judgeAttempts = [...(state.judgeAttempts || []), {
|
|
514
|
+
attempt,
|
|
515
|
+
status: state.judge?.status,
|
|
516
|
+
exitCode: state.judge?.exitCode ?? null,
|
|
517
|
+
startedAt: state.judge?.startedAt,
|
|
518
|
+
endedAt: state.judge?.endedAt,
|
|
519
|
+
archivedDir,
|
|
520
|
+
archivedAt: nowIso()
|
|
521
|
+
}];
|
|
522
|
+
}
|
|
523
|
+
|
|
482
524
|
async function rotateWorkerAttempt(state, task) {
|
|
483
525
|
const runDir = pathForRun(state.runId);
|
|
484
526
|
const workerDir = roleDir(runDir, 'worker', task.id);
|
|
@@ -580,6 +622,7 @@ export async function dispatchRun(runId) {
|
|
|
580
622
|
if (!state.tasks?.length) throw new Error('no tasks in plan');
|
|
581
623
|
if (state.status === 'batch_blocked') throw new Error('current batch is blocked by failed/unknown tasks');
|
|
582
624
|
if (allBatchesCompleted(state)) throw new Error('all batches completed; run final judge next');
|
|
625
|
+
approvePlanGate(state);
|
|
583
626
|
state.status = 'running';
|
|
584
627
|
await scheduleMoreWorkers(state);
|
|
585
628
|
recomputeRunStatus(state);
|
|
@@ -770,16 +813,26 @@ export async function startJudge(runId) {
|
|
|
770
813
|
return await withRunStateLock(runId, async () => {
|
|
771
814
|
const state = await loadRun(runId);
|
|
772
815
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
816
|
+
if (state.archived) throw new Error('archived run cannot be judged');
|
|
817
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be judged');
|
|
773
818
|
recomputeRunStatus(state);
|
|
819
|
+
if (state.judge?.status === 'running') throw new Error('judge already running');
|
|
820
|
+
if (state.judge?.status === 'completed') throw new Error('judge already completed');
|
|
774
821
|
if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
|
|
775
|
-
const
|
|
822
|
+
const runDir = pathForRun(runId);
|
|
823
|
+
if (state.judge?.status === 'failed') await rotateJudgeAttempt(state, runDir);
|
|
824
|
+
const outDir = roleDir(runDir, 'judge');
|
|
776
825
|
await ensureDir(outDir);
|
|
777
826
|
const judgeInputPath = path.join(outDir, 'judge_input.json');
|
|
778
827
|
const judgeInput = await buildJudgeInput(state);
|
|
779
828
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
780
829
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
781
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(
|
|
782
|
-
|
|
830
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
831
|
+
const previousJudgeAttempts = [
|
|
832
|
+
...(state.judgeAttempts || []).map(item => Number(item.attempt || 0)),
|
|
833
|
+
Number(state.judge?.attempt || 0)
|
|
834
|
+
].filter(Number.isFinite);
|
|
835
|
+
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: 1 + Math.max(0, ...previousJudgeAttempts) };
|
|
783
836
|
state.status = 'judging';
|
|
784
837
|
await saveRun(state);
|
|
785
838
|
child.onExit(async code => {
|
|
@@ -816,6 +869,7 @@ export async function autoAdvanceRun(runId, { appClient = null, startCreated = f
|
|
|
816
869
|
return await refreshRun(runId, appClient) || state;
|
|
817
870
|
}
|
|
818
871
|
if (state.status === 'planned') {
|
|
872
|
+
if (requiresPlanApproval(state)) return state;
|
|
819
873
|
try { state = await dispatchRun(runId); }
|
|
820
874
|
catch (error) { if (!/all batches completed|current batch is blocked/i.test(error.message || '')) throw error; }
|
|
821
875
|
return await refreshRun(runId, appClient) || state;
|
|
@@ -1230,7 +1284,7 @@ export function summaryOfRun(s) {
|
|
|
1230
1284
|
const tasks = s.tasks || [];
|
|
1231
1285
|
const workspacePath = s.workspacePath || s.repo || '';
|
|
1232
1286
|
const git = s.git || s.workspace?.git || null;
|
|
1233
|
-
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 })) };
|
|
1287
|
+
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 })) };
|
|
1234
1288
|
}
|
|
1235
1289
|
|
|
1236
1290
|
export async function readRunTaskText(runId) {
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun,
|
|
|
8
8
|
import { startAutoScheduler } from './scheduler.js';
|
|
9
9
|
|
|
10
10
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
11
|
+
const CODEX_INFO_TTL_MS = 30000;
|
|
12
|
+
let codexInfoCache = null;
|
|
11
13
|
|
|
12
14
|
function send(res, status, body, type = 'application/json') {
|
|
13
15
|
const data = type === 'application/json' ? JSON.stringify(body, null, 2) : body;
|
|
@@ -26,6 +28,13 @@ async function readBody(req) {
|
|
|
26
28
|
function notFound(res) { send(res, 404, { error: 'not found' }); }
|
|
27
29
|
function methodNotAllowed(res) { send(res, 405, { error: 'method not allowed' }); }
|
|
28
30
|
|
|
31
|
+
async function cachedCodexInfo(nowMs = Date.now()) {
|
|
32
|
+
if (codexInfoCache && codexInfoCache.expiresAt > nowMs) return codexInfoCache.value;
|
|
33
|
+
const value = await detectCodexInfo();
|
|
34
|
+
codexInfoCache = { value, expiresAt: nowMs + CODEX_INFO_TTL_MS };
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
async function serveStatic(req, res, pathname) {
|
|
30
39
|
let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
|
|
31
40
|
file = path.resolve(file);
|
|
@@ -46,7 +55,7 @@ async function handleApi(req, res, url, appClient) {
|
|
|
46
55
|
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
56
|
}
|
|
48
57
|
if (req.method === 'GET' && url.pathname === '/api/codex') {
|
|
49
|
-
return send(res, 200, { ok: true, codex: await
|
|
58
|
+
return send(res, 200, { ok: true, codex: await cachedCodexInfo() });
|
|
50
59
|
}
|
|
51
60
|
if (parts[1] === 'runs' && parts.length === 2) {
|
|
52
61
|
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
|