input-kanban 0.0.5 → 0.0.7
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 +10 -2
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/RELEASE_NOTES.md +69 -0
- package/bin/input-kanban-timestamp-events.js +34 -0
- package/package.json +3 -2
- package/public/assets/input-kanban-mask-icon.svg +3 -0
- package/public/index.html +253 -42
- package/src/orchestrator.js +60 -5
- package/src/runners/headlessRunner.js +32 -1
- package/src/runners/tmuxRunner.js +6 -3
- package/src/server.js +6 -2
package/ENVIRONMENT.md
CHANGED
|
@@ -8,7 +8,7 @@ CLI options take precedence over environment variables. Environment variables ta
|
|
|
8
8
|
|
|
9
9
|
- `PORT`: HTTP server port. Default: `8787`. CLI option: `--port`.
|
|
10
10
|
- `HOST`: HTTP bind host. Default: `127.0.0.1`. CLI option: `--host`.
|
|
11
|
-
- `KANBAN_DEFAULT_REPO`: Default target repository path for new runs. Default: the current working directory when `input-kanban` is launched. CLI option: `--repo`.
|
|
11
|
+
- `KANBAN_DEFAULT_REPO`: Default target repository path for new runs. Default: the current working directory when `input-kanban` is launched. CLI option: `--repo`. Creating a run validates that this path is inside a Git work tree.
|
|
12
12
|
- `KANBAN_RUNS_DIR`: Directory for run state, logs, and artifacts. Default: `.input-kanban/runs` under the user's home directory. CLI option: `--runs-dir`.
|
|
13
13
|
- `KANBAN_CODEX_BIN`: Codex CLI executable name or path. Default: `codex`. CLI option: `--codex-bin`.
|
|
14
14
|
- `KANBAN_RUNNER`: Runner mode. Supported values: `headless`, `tmux`. Default: `headless`. CLI option: `--runner`.
|
package/PROJECT_GUIDE.md
CHANGED
|
@@ -67,7 +67,7 @@ Supported options:
|
|
|
67
67
|
|
|
68
68
|
Default behavior:
|
|
69
69
|
|
|
70
|
-
- default repo: current working directory when `input-kanban` is launched;
|
|
70
|
+
- default repo: current working directory when `input-kanban` is launched; run creation validates that the selected repo is inside a Git work tree;
|
|
71
71
|
- default host: `127.0.0.1`;
|
|
72
72
|
- default port: `8787`;
|
|
73
73
|
- default runs directory: `~/.input-kanban/runs`;
|
|
@@ -147,6 +147,7 @@ Recovery options:
|
|
|
147
147
|
- Inspect `events.pretty`, `stderr.log`, `last_message.md`, and artifacts.
|
|
148
148
|
- Manually mark `failed` or `unknown` workers as completed if the user confirms the work is actually done.
|
|
149
149
|
- Manual completion writes `workers/<taskId>/manual_completion.json`.
|
|
150
|
+
- If the user pastes a manual success result, it is saved as `workers/<taskId>/manual_result.md` and included in final judge input.
|
|
150
151
|
- The UI preserves the original failed or unknown status while also showing the manual completion marker.
|
|
151
152
|
|
|
152
153
|
## Stop and Archive
|
|
@@ -255,6 +256,8 @@ runs/<runId>/planner/
|
|
|
255
256
|
runs/<runId>/planner_attempts/attempt-XX/
|
|
256
257
|
runs/<runId>/workers/<taskId>/
|
|
257
258
|
runs/<runId>/judge/judge_input.json
|
|
259
|
+
runs/<runId>/workers/<taskId>/events_timed.jsonl
|
|
260
|
+
runs/<runId>/workers/<taskId>/manual_result.md
|
|
258
261
|
runs/<runId>/judge/verdict.json
|
|
259
262
|
```
|
|
260
263
|
|
|
@@ -271,6 +274,7 @@ runs/<runId>/judge/verdict.json
|
|
|
271
274
|
- `POST /api/runs/:runId/judge`
|
|
272
275
|
- `POST /api/runs/:runId/stop`
|
|
273
276
|
- `POST /api/runs/:runId/archive`
|
|
277
|
+
- `PATCH /api/runs/:runId/label`
|
|
274
278
|
- `GET /api/runs/:runId/task-text`
|
|
275
279
|
- `GET /api/runs/:runId/tasks/:taskId/file?name=...`
|
|
276
280
|
- `POST /api/runs/:runId/tasks/:taskId/mark-completed`
|
|
@@ -336,7 +340,7 @@ notes or handoff.
|
|
|
336
340
|
1. Headless runner:
|
|
337
341
|
- Start the app with `input-kanban --runner headless --runs-dir <tmp-runs-dir> --repo <target-repo> --port <free-port>`.
|
|
338
342
|
- Create a small run, plan it, dispatch at least one worker, and run the final judge if the plan requires it.
|
|
339
|
-
- Verify the run state reports `runner: headless`, no task exposes `tmux` metadata, and role directories contain the expected `prompt.md`, `events.jsonl`, `stderr.log`, `last_message.md`, and `exit_code` files.
|
|
343
|
+
- Verify the run state reports `runner: headless`, no task exposes `tmux` metadata, and role directories contain the expected `prompt.md`, `events.jsonl`, `events_timed.jsonl`, `stderr.log`, `last_message.md`, and `exit_code` files.
|
|
340
344
|
- Stop the run and verify no unrelated local process is affected.
|
|
341
345
|
|
|
342
346
|
2. tmux runner, only when `tmux -V` succeeds:
|
|
@@ -354,6 +358,10 @@ notes or handoff.
|
|
|
354
358
|
- Verify the package includes `bin/`, `src/`, `public/`, `README.md`, `README.en.md`, `PROJECT_GUIDE.md`, `ENVIRONMENT.md`, and `package.json`.
|
|
355
359
|
- Verify no runtime run directories, local logs, or unrelated temporary artifacts are included.
|
|
356
360
|
|
|
361
|
+
## Release Notes
|
|
362
|
+
|
|
363
|
+
Keep a single repository-level `RELEASE_NOTES.md` with recent version history. Do not add one tracked `RELEASE_NOTES.vX.Y.Z.md` file per release. When creating a GitHub Release, use a temporary notes file or copy the relevant version section from `RELEASE_NOTES.md` into `gh release create --notes-file`.
|
|
364
|
+
|
|
357
365
|
## Change Guidelines
|
|
358
366
|
|
|
359
367
|
- Do not add automatic worker retry unless there is a verified rollback or idempotency mechanism.
|
package/README.en.md
CHANGED
|
@@ -56,7 +56,7 @@ input-kanban --open
|
|
|
56
56
|
|
|
57
57
|
Defaults:
|
|
58
58
|
|
|
59
|
-
- target repository: the current directory where `input-kanban` is launched
|
|
59
|
+
- target repository: the current directory where `input-kanban` is launched; creating a run validates that it is inside a Git work tree
|
|
60
60
|
- host: `127.0.0.1`
|
|
61
61
|
- port: `8787`
|
|
62
62
|
- runs directory: `~/.input-kanban/runs`
|
package/README.md
CHANGED
package/RELEASE_NOTES.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Release Notes
|
|
2
|
+
|
|
3
|
+
## v0.0.7
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Improve the run list cards: replace `Run ID` with a repository chip and copy action, add frozen duration, and hide rename buttons until hover/focus.
|
|
8
|
+
- Streamline dashboard flow: creating a run now automatically starts planning, selecting a task opens its execution log by default, and running-task conflicts show Chinese user-facing messages.
|
|
9
|
+
- Improve task detail ergonomics: copy actions are available for both final replies and verdict JSON, and manual success completion stores pasted human evidence.
|
|
10
|
+
- Add execution timing artifacts and summary chips, including `events_timed.jsonl`, command duration breakdowns, model/scheduler time, startup/teardown time, and system event counts.
|
|
11
|
+
- Strengthen run creation validation by rejecting missing target paths and directories outside a Git work tree before planning starts.
|
|
12
|
+
- Keep release notes in a single repository-level `RELEASE_NOTES.md` and include that file in the npm package.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- `npm run check` passed with 43 tests.
|
|
17
|
+
- `npm pack --dry-run` passed before publishing.
|
|
18
|
+
|
|
19
|
+
## v0.0.6
|
|
20
|
+
|
|
21
|
+
### Changes
|
|
22
|
+
|
|
23
|
+
- Validate the target repository when creating a run, rejecting missing paths and directories outside a Git work tree before planning starts.
|
|
24
|
+
- Add a compact copy button for the full repository path in the run detail header.
|
|
25
|
+
- Document the Git work tree requirement in the README, English README, environment reference, and project guide.
|
|
26
|
+
|
|
27
|
+
### Verification
|
|
28
|
+
|
|
29
|
+
- `npm run check` passed with 38 tests.
|
|
30
|
+
- `npm pack --dry-run` confirmed package contents before the version bump.
|
|
31
|
+
|
|
32
|
+
## v0.0.5
|
|
33
|
+
|
|
34
|
+
### Highlights
|
|
35
|
+
|
|
36
|
+
- Add Input Kanban branding icons to the dashboard header and browser tab.
|
|
37
|
+
- Add standard favicon and Apple touch icon assets so browsers can show the same visual identity as the page header.
|
|
38
|
+
- Align left run-list metadata with the compact chip style used in run details.
|
|
39
|
+
- Align batch-row metadata in the task table with the same chip style for `Batch ID`, `最大并发`, and `进度`.
|
|
40
|
+
|
|
41
|
+
### Notes
|
|
42
|
+
|
|
43
|
+
- This is a UI polish release after `v0.0.4`.
|
|
44
|
+
- No runner, scheduler, tmux, sandbox, or Codex execution behavior changes are included.
|
|
45
|
+
|
|
46
|
+
### Verification
|
|
47
|
+
|
|
48
|
+
- `npm run check` passed.
|
|
49
|
+
- `npm pack --dry-run` passed.
|
|
50
|
+
|
|
51
|
+
## v0.0.4
|
|
52
|
+
|
|
53
|
+
### Highlights
|
|
54
|
+
|
|
55
|
+
- Add tmux batch layout: one session per run, windows for planner, each batch, and judge, with batch windows showing an overview pane plus worker panes.
|
|
56
|
+
- Show formatted Codex execution output in tmux panes while keeping raw JSONL logs in `events.jsonl`.
|
|
57
|
+
- Add worker sandbox selection in the create form, including explicit `danger-full-access` for controlled test repositories.
|
|
58
|
+
- Refine the dashboard UI with compact run metadata chips, no redundant tmux badges, no file-viewer tmux panel, and role-specific file tabs.
|
|
59
|
+
- Freeze run duration after terminal states such as final judge completion, instead of continuing to count on auto-refresh.
|
|
60
|
+
|
|
61
|
+
### Notes
|
|
62
|
+
|
|
63
|
+
- `codex exec` is treated as non-interactive; tmux mode provides live terminal visibility but does not implement manual approval prompts.
|
|
64
|
+
- The dashboard exposes the run-level `tmux attach-session` copy action after tmux metadata is available.
|
|
65
|
+
- Use `danger-full-access` only when you explicitly want to relax worker sandbox limits in a controlled environment.
|
|
66
|
+
|
|
67
|
+
### Verification
|
|
68
|
+
|
|
69
|
+
- `npm run check` passed.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
|
|
6
|
+
const [eventsFile, timedEventsFile] = process.argv.slice(2);
|
|
7
|
+
if (!eventsFile || !timedEventsFile) {
|
|
8
|
+
console.error('usage: input-kanban-timestamp-events.js <events.jsonl> <events_timed.jsonl>');
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await fs.promises.mkdir(path.dirname(eventsFile), { recursive: true });
|
|
13
|
+
await fs.promises.mkdir(path.dirname(timedEventsFile), { recursive: true });
|
|
14
|
+
|
|
15
|
+
const events = fs.createWriteStream(eventsFile, { flags: 'a' });
|
|
16
|
+
const timed = fs.createWriteStream(timedEventsFile, { flags: 'a' });
|
|
17
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
18
|
+
|
|
19
|
+
for await (const line of rl) {
|
|
20
|
+
const rawLine = `${line}\n`;
|
|
21
|
+
events.write(rawLine);
|
|
22
|
+
process.stdout.write(rawLine);
|
|
23
|
+
const receivedAt = new Date().toISOString();
|
|
24
|
+
try {
|
|
25
|
+
timed.write(`${JSON.stringify({ receivedAt, event: JSON.parse(line) })}\n`);
|
|
26
|
+
} catch {
|
|
27
|
+
timed.write(`${JSON.stringify({ receivedAt, rawLine: line })}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await Promise.all([
|
|
32
|
+
new Promise(resolve => events.end(resolve)),
|
|
33
|
+
new Promise(resolve => timed.end(resolve))
|
|
34
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "input-kanban",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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-tmux-overview.js && node --check src/server.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/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"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {},
|
|
13
13
|
"description": "A local Codex orchestration kanban dashboard",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"public",
|
|
18
18
|
"README.md",
|
|
19
19
|
"README.en.md",
|
|
20
|
+
"RELEASE_NOTES.md",
|
|
20
21
|
"PROJECT_GUIDE.md",
|
|
21
22
|
"ENVIRONMENT.md"
|
|
22
23
|
],
|
package/public/index.html
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Input 看板</title>
|
|
7
|
-
<link rel="icon" type="image/png" sizes="32x32" href="/assets/input-kanban-favicon-32.png" />
|
|
8
|
-
<link rel="
|
|
7
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/assets/input-kanban-favicon-32.png?v=2" />
|
|
8
|
+
<link rel="shortcut icon" type="image/png" href="/assets/input-kanban-favicon-32.png?v=2" />
|
|
9
|
+
<link rel="mask-icon" href="/assets/input-kanban-mask-icon.svg" color="#2563eb" />
|
|
10
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/assets/input-kanban-apple-touch-icon.png?v=2" />
|
|
9
11
|
<style>
|
|
10
12
|
:root { --bg:#0b1220; --panel:#111827; --panel-2:#0f172a; --line:#334155; --line-strong:#64748b; --text:#e2e8f0; --muted:#94a3b8; --blue:#2563eb; --green:#166534; --red:#991b1b; --gray:#475569; --orange:#b45309; }
|
|
11
13
|
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
.run-card:hover { border-color: var(--line-strong); background: #162033; transform: translateY(-1px); }
|
|
67
69
|
.run-card.active { border-color: #60a5fa; box-shadow: inset 3px 0 0 #60a5fa; }
|
|
68
70
|
.run-card-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: flex-start; font-weight: 800; }
|
|
71
|
+
.run-card-name-wrap { min-width: 0; display: flex; align-items: flex-start; gap: 6px; }
|
|
69
72
|
.run-card-name { min-width: 0; overflow: hidden; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
70
73
|
.run-card-title .pill { flex: 0 0 auto; }
|
|
71
74
|
.run-card-id { margin-top: 5px; word-break: break-all; }
|
|
@@ -84,12 +87,21 @@
|
|
|
84
87
|
.log-panel { margin-top: 16px; }
|
|
85
88
|
.file-tabs button { font-size: 13px; }
|
|
86
89
|
.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; }
|
|
90
|
+
.rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
|
91
|
+
.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; }
|
|
92
|
+
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
87
93
|
.session-cell { word-break: break-all; }
|
|
88
94
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
89
95
|
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
90
96
|
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
91
|
-
.execution-summary { display: flex; flex-wrap: wrap; gap:
|
|
97
|
+
.execution-summary { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; margin: 8px 0 -2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px 10px 0 0; background: #020617; color: var(--muted); font-size: 12px; }
|
|
92
98
|
.execution-summary.hidden { display: none; }
|
|
99
|
+
.summary-chip { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 4px 7px; border: 1px solid var(--line); border-radius: 999px; background: #0f172a; color: #cbd5e1; line-height: 1.2; }
|
|
100
|
+
.summary-chip.command-type { background: rgba(37,99,235,.12); border-color: rgba(96,165,250,.35); }
|
|
101
|
+
.summary-break { flex-basis: 100%; height: 0; }
|
|
102
|
+
.summary-label { color: var(--muted); font-weight: 800; }
|
|
103
|
+
.summary-value { color: #e2e8f0; font-weight: 800; }
|
|
104
|
+
.summary-duration { color: #bfdbfe; font-weight: 800; }
|
|
93
105
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
94
106
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
95
107
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
@@ -97,6 +109,10 @@
|
|
|
97
109
|
.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); }
|
|
98
110
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
99
111
|
.floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
|
|
112
|
+
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(2,6,23,.72); }
|
|
113
|
+
.modal-backdrop.hidden { display: none; }
|
|
114
|
+
.modal-card { width: min(760px, 100%); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 16px; }
|
|
115
|
+
.modal-card textarea { min-height: 220px; }
|
|
100
116
|
</style>
|
|
101
117
|
</head>
|
|
102
118
|
<body>
|
|
@@ -153,21 +169,35 @@
|
|
|
153
169
|
</section>
|
|
154
170
|
|
|
155
171
|
<section id="filePanel" class="log-panel">
|
|
156
|
-
<h2
|
|
157
|
-
<div id="fileTitle" class="muted"
|
|
172
|
+
<h2>任务详情</h2>
|
|
173
|
+
<div id="fileTitle" class="muted">点击任务后查看详情</div>
|
|
158
174
|
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
159
175
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
160
176
|
<div class="file-content-wrap">
|
|
161
|
-
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="
|
|
177
|
+
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制当前内容" onclick="copyFileContent(event)">⧉</button>
|
|
162
178
|
<pre id="fileContent"></pre>
|
|
163
179
|
</div>
|
|
164
180
|
</section>
|
|
165
181
|
</div>
|
|
166
182
|
</main>
|
|
183
|
+
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
184
|
+
<div class="modal-card">
|
|
185
|
+
<h2>手动标记成功</h2>
|
|
186
|
+
<div id="manualCompleteTitle" class="muted"></div>
|
|
187
|
+
<label>人工成功执行结果</label>
|
|
188
|
+
<textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
|
|
189
|
+
<div class="muted">该内容会保存为 manual_result.md,显示在“结果”中,并作为 final judge 的人工成功证据。</div>
|
|
190
|
+
<div class="toolbar">
|
|
191
|
+
<button onclick="submitManualComplete()">确认标记成功</button>
|
|
192
|
+
<button class="secondary" onclick="closeManualCompleteModal()">取消</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
167
196
|
<script>
|
|
168
197
|
let selectedRun = null;
|
|
169
198
|
let selectedTask = null;
|
|
170
199
|
let selectedFileName = null;
|
|
200
|
+
let manualCompleteTaskId = null;
|
|
171
201
|
let currentState = null;
|
|
172
202
|
let lastAutoRefreshAt = null;
|
|
173
203
|
let runListVisibleCount = 10;
|
|
@@ -178,10 +208,26 @@ const RUN_LIST_PAGE_SIZE = 10;
|
|
|
178
208
|
|
|
179
209
|
async function api(path, opts={}) {
|
|
180
210
|
const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
|
181
|
-
if (!res.ok)
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const error = new Error(await res.text());
|
|
213
|
+
error.status = res.status;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
182
216
|
const ct = res.headers.get('content-type') || '';
|
|
183
217
|
return ct.includes('application/json') ? res.json() : res.text();
|
|
184
218
|
}
|
|
219
|
+
function errorDetail(error) {
|
|
220
|
+
const message = error?.message || String(error || '');
|
|
221
|
+
try { return JSON.parse(message).error || message; }
|
|
222
|
+
catch { return message; }
|
|
223
|
+
}
|
|
224
|
+
function userFacingErrorMessage(error) {
|
|
225
|
+
const detail = errorDetail(error);
|
|
226
|
+
if (/planner already running/i.test(detail)) return '任务拆分正在进行中,请稍后查看结果。';
|
|
227
|
+
if (/judge already running/i.test(detail)) return '验收正在进行中,请稍后查看结果。';
|
|
228
|
+
if (/already running/i.test(detail)) return '任务正在进行中,请稍后查看结果。';
|
|
229
|
+
return error?.message || String(error);
|
|
230
|
+
}
|
|
185
231
|
const statusText = {
|
|
186
232
|
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
187
233
|
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
@@ -222,7 +268,7 @@ function runDurationEnd(s) {
|
|
|
222
268
|
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
223
269
|
]) || s.updatedAt;
|
|
224
270
|
}
|
|
225
|
-
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))
|
|
271
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
|
|
226
272
|
function basenamePath(value) {
|
|
227
273
|
const parts = String(value || '').split(/[\\/]/).filter(Boolean);
|
|
228
274
|
return parts.at(-1) || value || '-';
|
|
@@ -230,6 +276,9 @@ function basenamePath(value) {
|
|
|
230
276
|
function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
|
|
231
277
|
return `<span class="meta-chip ${danger ? 'danger' : ''} ${long ? 'long' : ''}" title="${esc(title)}"><span class="meta-label">${esc(label)}</span><span class="meta-value">${esc(value)}</span>${extra}</span>`;
|
|
232
278
|
}
|
|
279
|
+
function editIcon() {
|
|
280
|
+
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>';
|
|
281
|
+
}
|
|
233
282
|
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
234
283
|
function taskById(id) {
|
|
235
284
|
if (!currentState) return null;
|
|
@@ -250,6 +299,14 @@ function runAttachCommand(state = currentState) {
|
|
|
250
299
|
if (!hasRunTmuxMetadata(state)) return '';
|
|
251
300
|
return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
|
|
252
301
|
}
|
|
302
|
+
function runCardDurationEnd(run) {
|
|
303
|
+
const cached = statusByRunId.get(run.runId);
|
|
304
|
+
if (cached) return runDurationEnd(cached);
|
|
305
|
+
return run.durationEnd || null;
|
|
306
|
+
}
|
|
307
|
+
function runCardDurationText(run) {
|
|
308
|
+
return formatDurationMs(durationSeconds(run.createdAt, runCardDurationEnd(run)) * 1000);
|
|
309
|
+
}
|
|
253
310
|
function runListHasTmuxMetadata(run) {
|
|
254
311
|
if (run?.runner !== 'tmux') return false;
|
|
255
312
|
if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
|
|
@@ -282,6 +339,8 @@ async function createRun() {
|
|
|
282
339
|
clearFileView();
|
|
283
340
|
hideCreateForm();
|
|
284
341
|
await refreshRuns(); await refreshSelected();
|
|
342
|
+
await api(`/api/runs/${selectedRun}/plan`, { method: 'POST' });
|
|
343
|
+
await refreshSelected();
|
|
285
344
|
}
|
|
286
345
|
async function refreshRuns() {
|
|
287
346
|
const data = await api('/api/runs');
|
|
@@ -292,10 +351,11 @@ function renderRunList() {
|
|
|
292
351
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
293
352
|
const cards = visibleRuns.map(r => `
|
|
294
353
|
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
|
|
295
|
-
<div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span>${pill(r.status)}</span></div>
|
|
354
|
+
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button></span><span>${pill(r.status)}</span></div>
|
|
296
355
|
<div class="run-card-meta">
|
|
297
|
-
${metaChip('
|
|
356
|
+
${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
|
|
298
357
|
${metaChip('创建', formatDateTime(r.createdAt))}
|
|
358
|
+
${metaChip('用时', runCardDurationText(r))}
|
|
299
359
|
${metaChip('进度', `${r.completed}/${r.total}`)}
|
|
300
360
|
${metaChip('执行中', r.running)}
|
|
301
361
|
${metaChip('失败', r.failed, { danger: Number(r.failed) > 0 })}
|
|
@@ -328,10 +388,14 @@ function renderSelectedHeader() {
|
|
|
328
388
|
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
329
389
|
const chips = [
|
|
330
390
|
metaChip('Run ID', currentState.runId, { long: true }),
|
|
331
|
-
metaChip('仓库', basenamePath(currentState.repo), {
|
|
391
|
+
metaChip('仓库', basenamePath(currentState.repo), {
|
|
392
|
+
title: currentState.repo,
|
|
393
|
+
long: true,
|
|
394
|
+
extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRepoPath(event)">⧉</button>`
|
|
395
|
+
}),
|
|
332
396
|
metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
|
|
333
397
|
metaChip('开始', formatDateTime(currentState.createdAt)),
|
|
334
|
-
metaChip('用时',
|
|
398
|
+
metaChip('用时', formatDurationMs(durationSeconds(currentState.createdAt, runDurationEnd(currentState)) * 1000))
|
|
335
399
|
];
|
|
336
400
|
if (currentState.runner === 'tmux') {
|
|
337
401
|
if (hasRunTmuxMetadata(currentState)) {
|
|
@@ -345,7 +409,7 @@ function renderSelectedHeader() {
|
|
|
345
409
|
}
|
|
346
410
|
chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
|
|
347
411
|
chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
|
|
348
|
-
return `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
|
|
412
|
+
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>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
|
|
349
413
|
}
|
|
350
414
|
async function loadTaskDescription() {
|
|
351
415
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
@@ -405,10 +469,17 @@ function sessionCell(thread) {
|
|
|
405
469
|
function taskStartedCell(t) {
|
|
406
470
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
407
471
|
}
|
|
408
|
-
function
|
|
409
|
-
if (!t?.startedAt) return
|
|
472
|
+
function taskDurationMs(t) {
|
|
473
|
+
if (!t?.startedAt) return null;
|
|
410
474
|
const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
|
|
411
|
-
|
|
475
|
+
const endMs = end ? new Date(end).getTime() : Date.now();
|
|
476
|
+
const startMs = new Date(t.startedAt).getTime();
|
|
477
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
478
|
+
return Math.max(0, endMs - startMs);
|
|
479
|
+
}
|
|
480
|
+
function taskDurationCell(t) {
|
|
481
|
+
const ms = taskDurationMs(t);
|
|
482
|
+
return ms === null ? '-' : formatDurationMs(ms);
|
|
412
483
|
}
|
|
413
484
|
function processExitCell(t) {
|
|
414
485
|
return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
|
|
@@ -464,12 +535,17 @@ async function selectTask(id) {
|
|
|
464
535
|
document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
|
|
465
536
|
renderFileTabs();
|
|
466
537
|
renderTasks();
|
|
467
|
-
|
|
538
|
+
const tabs = fileTabsForSelectedTask();
|
|
539
|
+
if (selectedFileName && tabs.some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
|
|
468
540
|
else {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
541
|
+
const defaultTab = tabs.find(([name]) => name === 'events.pretty') || tabs[0];
|
|
542
|
+
if (defaultTab) await loadFile(defaultTab[0]);
|
|
543
|
+
else {
|
|
544
|
+
selectedFileName = null;
|
|
545
|
+
document.getElementById('fileContent').textContent = '';
|
|
546
|
+
hideExecutionSummary();
|
|
547
|
+
updateCopyLastMessageButton();
|
|
548
|
+
}
|
|
473
549
|
}
|
|
474
550
|
}
|
|
475
551
|
async function loadFile(name, { preserveScroll = false } = {}) {
|
|
@@ -477,7 +553,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
477
553
|
selectedFileName = name;
|
|
478
554
|
const pre = document.getElementById('fileContent');
|
|
479
555
|
const previousScrollTop = pre.scrollTop;
|
|
480
|
-
|
|
556
|
+
let text;
|
|
557
|
+
const selected = taskById(selectedTask);
|
|
558
|
+
if (name === 'result.json' && selected?.manualCompletion?.hasManualResult) {
|
|
559
|
+
const manualText = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=manual_result.md`);
|
|
560
|
+
text = manualText ? `【这次是人工结果】\n\n${manualText}` : await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
561
|
+
} else {
|
|
562
|
+
text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
563
|
+
}
|
|
481
564
|
pre.textContent = text;
|
|
482
565
|
if (preserveScroll) pre.scrollTop = previousScrollTop;
|
|
483
566
|
else pre.scrollTop = 0;
|
|
@@ -486,7 +569,7 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
486
569
|
updateCopyLastMessageButton();
|
|
487
570
|
}
|
|
488
571
|
function clearFileView() {
|
|
489
|
-
document.getElementById('fileTitle').textContent = '
|
|
572
|
+
document.getElementById('fileTitle').textContent = '点击任务后查看详情';
|
|
490
573
|
document.getElementById('fileContent').textContent = '';
|
|
491
574
|
const tabs = document.getElementById('fileTabs');
|
|
492
575
|
if (tabs) tabs.innerHTML = '';
|
|
@@ -496,7 +579,9 @@ function clearFileView() {
|
|
|
496
579
|
function updateCopyLastMessageButton() {
|
|
497
580
|
const button = document.getElementById('copyLastMessageBtn');
|
|
498
581
|
if (!button) return;
|
|
499
|
-
|
|
582
|
+
const copyableFiles = new Set(['last_message.md', 'verdict.json']);
|
|
583
|
+
button.classList.toggle('hidden', !copyableFiles.has(selectedFileName));
|
|
584
|
+
button.title = selectedFileName === 'verdict.json' ? '复制验收结论内容' : '复制最终回复内容';
|
|
500
585
|
button.textContent = '⧉';
|
|
501
586
|
}
|
|
502
587
|
async function copyFileContent(event) {
|
|
@@ -516,6 +601,21 @@ function hideExecutionSummary() {
|
|
|
516
601
|
el.classList.add('hidden');
|
|
517
602
|
el.innerHTML = '';
|
|
518
603
|
}
|
|
604
|
+
async function copyRepoPath(event, repoPath = currentState?.repo || '') {
|
|
605
|
+
event.stopPropagation();
|
|
606
|
+
if (!repoPath) return;
|
|
607
|
+
try {
|
|
608
|
+
await navigator.clipboard.writeText(repoPath);
|
|
609
|
+
event.currentTarget.textContent = '已复制';
|
|
610
|
+
setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
|
|
611
|
+
} catch {
|
|
612
|
+
prompt('复制仓库地址', repoPath);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async function copyRunRepoPath(event, runId) {
|
|
616
|
+
const repoPath = latestRuns.find(run => run.runId === runId)?.repo || '';
|
|
617
|
+
await copyRepoPath(event, repoPath);
|
|
618
|
+
}
|
|
519
619
|
async function copyTmuxRunCommand(event) {
|
|
520
620
|
event.stopPropagation();
|
|
521
621
|
const command = runAttachCommand(currentState);
|
|
@@ -531,32 +631,109 @@ async function copyTmuxRunCommand(event) {
|
|
|
531
631
|
async function renderExecutionSummary() {
|
|
532
632
|
const el = document.getElementById('executionSummary');
|
|
533
633
|
let raw = '';
|
|
634
|
+
let timedRaw = '';
|
|
534
635
|
try { raw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events.jsonl`); }
|
|
535
636
|
catch { raw = ''; }
|
|
536
|
-
|
|
637
|
+
try { timedRaw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events_timed.jsonl`); }
|
|
638
|
+
catch { timedRaw = ''; }
|
|
639
|
+
const task = taskById(selectedTask);
|
|
640
|
+
const taskMs = taskDurationMs(task);
|
|
641
|
+
const summary = summarizeEventsJsonl(timedRaw || raw, { taskMs });
|
|
642
|
+
const taskDuration = taskDurationCell(task);
|
|
643
|
+
const statChip = (label, value) => `<span class="summary-chip"><span class="summary-label">${esc(label)}</span><span class="summary-value">${esc(value)}</span></span>`;
|
|
644
|
+
const commandTypeChips = summary.commandTypes.length
|
|
645
|
+
? summary.commandTypes.map(item => `<span class="summary-chip command-type"><span class="summary-value">${esc(item.kind)}</span><span>${item.count}次</span><span class="summary-duration">${esc(item.durationText)}</span><span>占比 ${esc(item.percentText)}</span></span>`).join('')
|
|
646
|
+
: '<span class="summary-chip command-type"><span class="summary-value">-</span></span>';
|
|
537
647
|
el.classList.remove('hidden');
|
|
538
|
-
el.innerHTML =
|
|
539
|
-
}
|
|
540
|
-
function
|
|
541
|
-
const
|
|
648
|
+
el.innerHTML = `${statChip('任务用时', taskDuration)}${statChip('命令用时', summary.commandDurationText)}${statChip('模型/调度', summary.modelOrchestrationText)}${statChip('启动/收尾', summary.startFinishText)}${statChip('事件', summary.events)}${statChip('命令开始', summary.commandStarted)}${statChip('命令完成', summary.commandCompleted)}${statChip('模型回复', summary.agentMessages)}${statChip('推理', summary.reasoning)}${statChip('MCP', summary.mcpCalls)}${statChip('文件变更', summary.fileChanges)}${statChip('系统事件', summary.systemEvents)}<span class="summary-break"></span><span class="summary-label">命令类型</span>${commandTypeChips}<button class="secondary copy-btn" onclick="scrollFileToBottom()">跳到末尾</button>`;
|
|
649
|
+
}
|
|
650
|
+
function eventTimeMs(event) {
|
|
651
|
+
const value = event.receivedAt ?? event.timestamp ?? event.time ?? event.created_at ?? event.createdAt ?? event.ts;
|
|
652
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;
|
|
653
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
654
|
+
const ms = Date.parse(value);
|
|
655
|
+
return Number.isFinite(ms) ? ms : null;
|
|
656
|
+
}
|
|
657
|
+
function formatDurationMs(ms) {
|
|
658
|
+
if (!Number.isFinite(ms) || ms < 0) return '-';
|
|
659
|
+
if (ms < 1000) return `${Math.round(ms)}毫秒`;
|
|
660
|
+
const seconds = ms / 1000;
|
|
661
|
+
if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}秒`;
|
|
662
|
+
const minutes = Math.floor(seconds / 60);
|
|
663
|
+
const rest = Math.round(seconds % 60);
|
|
664
|
+
return `${minutes}分${String(rest).padStart(2, '0')}秒`;
|
|
665
|
+
}
|
|
666
|
+
function summarizeEventsJsonl(raw, { taskMs = null } = {}) {
|
|
667
|
+
const summary = { events: 0, commandStarted: 0, commandCompleted: 0, mcpCalls: 0, fileChanges: 0, agentMessages: 0, reasoning: 0, systemEvents: 0, commandKinds: new Map(), commandKindsText: '-', commandTypes: [], eventDurationText: '-', commandDurationText: '-', modelOrchestrationText: '-', startFinishText: '-' };
|
|
668
|
+
const commandStarts = new Map();
|
|
669
|
+
const commandCountsByKind = new Map();
|
|
670
|
+
const commandDurationsByKind = new Map();
|
|
671
|
+
let firstEventMs = null;
|
|
672
|
+
let lastEventMs = null;
|
|
673
|
+
let commandDurationTotalMs = 0;
|
|
542
674
|
for (const line of String(raw || '').split(/\r?\n/).filter(Boolean)) {
|
|
543
675
|
let event;
|
|
544
676
|
try { event = JSON.parse(line); } catch { continue; }
|
|
677
|
+
const envelopeTimeMs = eventTimeMs(event);
|
|
678
|
+
event = event.event || event;
|
|
545
679
|
summary.events++;
|
|
680
|
+
const timeMs = envelopeTimeMs ?? eventTimeMs(event);
|
|
681
|
+
if (timeMs !== null) {
|
|
682
|
+
firstEventMs = firstEventMs === null ? timeMs : Math.min(firstEventMs, timeMs);
|
|
683
|
+
lastEventMs = lastEventMs === null ? timeMs : Math.max(lastEventMs, timeMs);
|
|
684
|
+
}
|
|
546
685
|
const item = event.item || {};
|
|
547
686
|
const type = item.type || '';
|
|
548
687
|
if (type === 'command_execution') {
|
|
549
|
-
|
|
550
|
-
if (event.type === 'item.completed') summary.commandCompleted++;
|
|
688
|
+
const commandId = item.id || `command-${summary.commandStarted + summary.commandCompleted}`;
|
|
551
689
|
const kind = commandKind(item.command || 'unknown');
|
|
690
|
+
if (event.type === 'item.started') {
|
|
691
|
+
summary.commandStarted++;
|
|
692
|
+
if (timeMs !== null) commandStarts.set(commandId, { timeMs, kind });
|
|
693
|
+
}
|
|
694
|
+
if (event.type === 'item.completed') {
|
|
695
|
+
summary.commandCompleted++;
|
|
696
|
+
const started = commandStarts.get(commandId);
|
|
697
|
+
const completedKind = started?.kind || kind;
|
|
698
|
+
commandCountsByKind.set(completedKind, (commandCountsByKind.get(completedKind) || 0) + 1);
|
|
699
|
+
const inlineDuration = Number(item.duration_ms ?? item.durationMs ?? event.duration_ms ?? event.durationMs);
|
|
700
|
+
let durationMs = null;
|
|
701
|
+
if (Number.isFinite(inlineDuration) && inlineDuration >= 0) durationMs = inlineDuration;
|
|
702
|
+
else if (timeMs !== null && started && Number.isFinite(started.timeMs)) durationMs = Math.max(0, timeMs - started.timeMs);
|
|
703
|
+
if (durationMs !== null) {
|
|
704
|
+
commandDurationTotalMs += durationMs;
|
|
705
|
+
const current = commandDurationsByKind.get(completedKind) || { ms: 0, count: 0 };
|
|
706
|
+
commandDurationsByKind.set(completedKind, { ms: current.ms + durationMs, count: current.count + 1 });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
552
709
|
summary.commandKinds.set(kind, (summary.commandKinds.get(kind) || 0) + 1);
|
|
553
710
|
}
|
|
554
|
-
if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
|
|
555
|
-
if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
|
|
556
|
-
if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
|
|
557
|
-
if (type === 'reasoning') summary.reasoning++;
|
|
711
|
+
else if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
|
|
712
|
+
else if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
|
|
713
|
+
else if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
|
|
714
|
+
else if (type === 'reasoning') summary.reasoning++;
|
|
715
|
+
else summary.systemEvents++;
|
|
716
|
+
}
|
|
717
|
+
let eventDurationMs = null;
|
|
718
|
+
if (firstEventMs !== null && lastEventMs !== null) {
|
|
719
|
+
eventDurationMs = Math.max(0, lastEventMs - firstEventMs);
|
|
720
|
+
summary.eventDurationText = formatDurationMs(eventDurationMs);
|
|
558
721
|
}
|
|
559
|
-
|
|
722
|
+
if (commandDurationTotalMs > 0) summary.commandDurationText = formatDurationMs(commandDurationTotalMs);
|
|
723
|
+
if (eventDurationMs !== null) summary.modelOrchestrationText = formatDurationMs(Math.max(0, eventDurationMs - commandDurationTotalMs));
|
|
724
|
+
if (eventDurationMs !== null && Number.isFinite(taskMs) && taskMs >= 0) summary.startFinishText = formatDurationMs(Math.max(0, taskMs - eventDurationMs));
|
|
725
|
+
const commandTypeNames = new Set([...commandCountsByKind.keys(), ...commandDurationsByKind.keys()]);
|
|
726
|
+
summary.commandTypes = [...commandTypeNames].map(kind => {
|
|
727
|
+
const duration = commandDurationsByKind.get(kind);
|
|
728
|
+
return {
|
|
729
|
+
kind,
|
|
730
|
+
count: commandCountsByKind.get(kind) || duration?.count || 0,
|
|
731
|
+
durationMs: duration?.ms ?? null,
|
|
732
|
+
durationText: duration ? formatDurationMs(duration.ms) : '-',
|
|
733
|
+
percentText: duration && Number.isFinite(taskMs) && taskMs > 0 ? `${Math.round((duration.ms / taskMs) * 100)}%` : '-'
|
|
734
|
+
};
|
|
735
|
+
}).sort((a, b) => b.count - a.count || (b.durationMs || 0) - (a.durationMs || 0)).slice(0, 6);
|
|
736
|
+
const kinds = summary.commandTypes.map(item => [item.kind, item.count]);
|
|
560
737
|
summary.commandKindsText = kinds.length ? kinds.map(([kind, count]) => `${kind}:${count}`).join(' / ') : '-';
|
|
561
738
|
return summary;
|
|
562
739
|
}
|
|
@@ -586,12 +763,32 @@ async function copySessionId(event, thread) {
|
|
|
586
763
|
prompt('复制 Codex 会话ID', thread);
|
|
587
764
|
}
|
|
588
765
|
}
|
|
766
|
+
function labelForRun(runId) {
|
|
767
|
+
if (currentState?.runId === runId) return currentState.label || '';
|
|
768
|
+
return (latestRuns.find(run => run.runId === runId)?.label) || '';
|
|
769
|
+
}
|
|
770
|
+
async function renameRunLabel(event, runId = selectedRun) {
|
|
771
|
+
event.stopPropagation();
|
|
772
|
+
if (!runId) return;
|
|
773
|
+
const nextLabel = prompt('修改任务批次名称', labelForRun(runId));
|
|
774
|
+
if (nextLabel === null) return;
|
|
775
|
+
const label = nextLabel.trim();
|
|
776
|
+
if (!label) { alert('任务批次名称不能为空'); return; }
|
|
777
|
+
await runAction(async () => {
|
|
778
|
+
await api(`/api/runs/${runId}/label`, { method: 'PATCH', body: JSON.stringify({ label }) });
|
|
779
|
+
if (selectedRun === runId) await refreshSelected();
|
|
780
|
+
else await refreshRuns();
|
|
781
|
+
});
|
|
782
|
+
}
|
|
589
783
|
async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
590
784
|
async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
|
|
591
785
|
async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
592
786
|
async function runAction(fn) {
|
|
593
787
|
try { await fn(); }
|
|
594
|
-
catch (error) {
|
|
788
|
+
catch (error) {
|
|
789
|
+
console.error('操作失败', error);
|
|
790
|
+
alert(userFacingErrorMessage(error));
|
|
791
|
+
}
|
|
595
792
|
}
|
|
596
793
|
async function stopSelectedRun() {
|
|
597
794
|
if (!selectedRun) return;
|
|
@@ -624,15 +821,29 @@ async function markTaskCompleted(event, taskId) {
|
|
|
624
821
|
if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
|
|
625
822
|
const task = (currentState?.tasks || []).find(t => t.id === taskId);
|
|
626
823
|
if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
824
|
+
manualCompleteTaskId = taskId;
|
|
825
|
+
document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
|
|
826
|
+
document.getElementById('manualCompleteResult').value = '';
|
|
827
|
+
document.getElementById('manualCompleteModal').classList.remove('hidden');
|
|
828
|
+
setTimeout(() => document.getElementById('manualCompleteResult').focus(), 0);
|
|
829
|
+
}
|
|
830
|
+
function closeManualCompleteModal() {
|
|
831
|
+
manualCompleteTaskId = null;
|
|
832
|
+
document.getElementById('manualCompleteModal').classList.add('hidden');
|
|
833
|
+
}
|
|
834
|
+
async function submitManualComplete() {
|
|
835
|
+
const taskId = manualCompleteTaskId;
|
|
836
|
+
if (!selectedRun || !taskId) return;
|
|
837
|
+
const resultText = document.getElementById('manualCompleteResult').value.trim();
|
|
838
|
+
if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
|
|
630
839
|
await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
|
|
631
840
|
method: 'POST',
|
|
632
|
-
body: JSON.stringify({ reason: 'manual success confirmed from dashboard' })
|
|
841
|
+
body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
|
|
633
842
|
});
|
|
843
|
+
closeManualCompleteModal();
|
|
844
|
+
selectedTask = taskId;
|
|
634
845
|
await refreshSelected();
|
|
635
|
-
await loadFile('
|
|
846
|
+
await loadFile('result.json');
|
|
636
847
|
}
|
|
637
848
|
|
|
638
849
|
loadHealth().then(refreshRuns);
|
package/src/orchestrator.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
4
6
|
import {
|
|
5
7
|
DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
6
8
|
writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
|
|
@@ -10,6 +12,7 @@ import { matchThreadToMarkers } from './appServerClient.js';
|
|
|
10
12
|
import { formatCodexEventsJsonl } from './eventFormatter.js';
|
|
11
13
|
import { defaultRunner } from './runners/index.js';
|
|
12
14
|
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
13
16
|
const runner = defaultRunner;
|
|
14
17
|
const VALID_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
15
18
|
|
|
@@ -22,13 +25,33 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
|
22
25
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
23
26
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
24
27
|
|
|
28
|
+
function userInputError(message) {
|
|
29
|
+
const error = new Error(message);
|
|
30
|
+
error.statusCode = 400;
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function assertGitWorkTree(repo) {
|
|
35
|
+
const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
|
|
36
|
+
let stat;
|
|
37
|
+
try { stat = await fsp.stat(resolvedRepo); }
|
|
38
|
+
catch { throw userInputError(`target repository does not exist: ${resolvedRepo}`); }
|
|
39
|
+
if (!stat.isDirectory()) throw userInputError(`target repository is not a directory: ${resolvedRepo}`);
|
|
40
|
+
try {
|
|
41
|
+
const { stdout } = await execFileAsync('git', ['-C', resolvedRepo, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
|
|
42
|
+
if (stdout.trim() === 'true') return resolvedRepo;
|
|
43
|
+
} catch {}
|
|
44
|
+
throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
export async function createRun({ label = 'task', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
48
|
+
const resolvedRepo = await assertGitWorkTree(repo);
|
|
26
49
|
const runId = makeRunId(label);
|
|
27
50
|
const runDir = pathForRun(runId);
|
|
28
51
|
await ensureDir(runDir);
|
|
29
52
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
30
53
|
const state = {
|
|
31
|
-
runId, label, repo:
|
|
54
|
+
runId, label, repo: resolvedRepo, maxParallel: Number(maxParallel) || 3, workerSandbox: normalizeSandbox(workerSandbox),
|
|
32
55
|
runner: RUNNER,
|
|
33
56
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
34
57
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -333,7 +356,18 @@ export async function archiveRun(runId, { reason = 'archived by user' } = {}) {
|
|
|
333
356
|
return state;
|
|
334
357
|
}
|
|
335
358
|
|
|
336
|
-
export async function
|
|
359
|
+
export async function renameRun(runId, { label = '' } = {}) {
|
|
360
|
+
const state = await loadRun(runId);
|
|
361
|
+
if (!state) throw new Error(`run not found: ${runId}`);
|
|
362
|
+
const nextLabel = String(label || '').trim();
|
|
363
|
+
if (!nextLabel) throw userInputError('run label cannot be empty');
|
|
364
|
+
state.label = nextLabel;
|
|
365
|
+
state.renamedAt = nowIso();
|
|
366
|
+
await saveRun(state);
|
|
367
|
+
return state;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function markTaskCompleted(runId, taskId, { reason = 'manual success confirmed by user', resultText = '' } = {}) {
|
|
337
371
|
const state = await loadRun(runId);
|
|
338
372
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
339
373
|
const task = (state.tasks || []).find(t => t.id === taskId);
|
|
@@ -343,6 +377,8 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
|
|
|
343
377
|
const outDir = roleDir(runDir, 'worker', task.id);
|
|
344
378
|
await ensureDir(outDir);
|
|
345
379
|
if (task.status !== 'completed') {
|
|
380
|
+
const manualResult = String(resultText || '').trim();
|
|
381
|
+
if (manualResult) await fsp.writeFile(path.join(outDir, 'manual_result.md'), manualResult);
|
|
346
382
|
const override = {
|
|
347
383
|
type: 'manual_task_completed',
|
|
348
384
|
runId,
|
|
@@ -352,6 +388,9 @@ export async function markTaskCompleted(runId, taskId, { reason = 'manual succes
|
|
|
352
388
|
previousStatus: task.status,
|
|
353
389
|
previousExitCode: task.exitCode ?? null,
|
|
354
390
|
reason,
|
|
391
|
+
hasManualResult: !!manualResult,
|
|
392
|
+
manualResultFile: manualResult ? 'manual_result.md' : null,
|
|
393
|
+
manualResultPreview: manualResult ? manualResult.slice(0, 500) : '',
|
|
355
394
|
markedAt: nowIso()
|
|
356
395
|
};
|
|
357
396
|
await writeJsonAtomic(path.join(outDir, 'manual_completion.json'), override);
|
|
@@ -556,11 +595,13 @@ async function standardFiles(dir) {
|
|
|
556
595
|
return {
|
|
557
596
|
prompt: await fileInfo(path.join(dir, 'prompt.md')),
|
|
558
597
|
events: await fileInfo(path.join(dir, 'events.jsonl')),
|
|
598
|
+
timedEvents: await fileInfo(path.join(dir, 'events_timed.jsonl')),
|
|
559
599
|
stderr: await fileInfo(path.join(dir, 'stderr.log')),
|
|
560
600
|
lastMessage: await fileInfo(path.join(dir, 'last_message.md')),
|
|
561
601
|
exitCode: await fileInfo(path.join(dir, 'exit_code')),
|
|
562
602
|
runScript: await fileInfo(path.join(dir, 'run.sh')),
|
|
563
|
-
tmux: await fileInfo(path.join(dir, 'tmux.json'))
|
|
603
|
+
tmux: await fileInfo(path.join(dir, 'tmux.json')),
|
|
604
|
+
manualResult: await fileInfo(path.join(dir, 'manual_result.md'))
|
|
564
605
|
};
|
|
565
606
|
}
|
|
566
607
|
|
|
@@ -651,6 +692,7 @@ async function buildJudgeInput(state) {
|
|
|
651
692
|
resultJson: await readJson(path.join(dir, 'result.json'), null),
|
|
652
693
|
evidenceJson: await readJson(path.join(dir, 'evidence.json'), null),
|
|
653
694
|
manualCompletion: task.manualCompletion || await readJson(path.join(dir, 'manual_completion.json'), null),
|
|
695
|
+
manualResult: await readTextMaybe(path.join(dir, 'manual_result.md'), 200000),
|
|
654
696
|
tmux: task.tmux || null,
|
|
655
697
|
stderrTail: await readTextMaybe(path.join(dir, 'stderr.log'), 20000)
|
|
656
698
|
});
|
|
@@ -715,9 +757,22 @@ async function enrichFromAppServer(state, appClient) {
|
|
|
715
757
|
}
|
|
716
758
|
}
|
|
717
759
|
|
|
760
|
+
function runDurationEndOfState(s) {
|
|
761
|
+
const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
|
|
762
|
+
if (!terminalStatuses.has(s.status)) return null;
|
|
763
|
+
const times = [
|
|
764
|
+
s.stoppedAt,
|
|
765
|
+
s.stopInfo?.stoppedAt,
|
|
766
|
+
s.judge?.endedAt,
|
|
767
|
+
s.planner?.endedAt,
|
|
768
|
+
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
769
|
+
].map(value => Date.parse(value || '')).filter(Number.isFinite);
|
|
770
|
+
return times.length ? new Date(Math.max(...times)).toISOString() : s.updatedAt;
|
|
771
|
+
}
|
|
772
|
+
|
|
718
773
|
function summaryOfRun(s) {
|
|
719
774
|
const tasks = s.tasks || [];
|
|
720
|
-
return { runId: s.runId, label: s.label, repo: s.repo, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, 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 })) };
|
|
775
|
+
return { runId: s.runId, label: s.label, repo: s.repo, 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 })) };
|
|
721
776
|
}
|
|
722
777
|
|
|
723
778
|
export async function readRunTaskText(runId) {
|
|
@@ -726,7 +781,7 @@ export async function readRunTaskText(runId) {
|
|
|
726
781
|
|
|
727
782
|
export async function readRunFile(runId, taskId, name) {
|
|
728
783
|
const runDir = pathForRun(runId);
|
|
729
|
-
const allowed = new Set(['prompt.md','events.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','run.sh','tmux.json']);
|
|
784
|
+
const allowed = new Set(['prompt.md','events.jsonl','events_timed.jsonl','events.pretty','stderr.log','last_message.md','exit_code','result.json','evidence.json','verdict.json','judge_input.json','manual_completion.json','manual_result.md','run.sh','tmux.json']);
|
|
730
785
|
if (!allowed.has(name)) throw new Error('file not allowed');
|
|
731
786
|
let dir;
|
|
732
787
|
if (taskId === 'planner') dir = roleDir(runDir, 'planner');
|
|
@@ -7,17 +7,48 @@ function processKey(runId, taskId) {
|
|
|
7
7
|
return `${runId}:${taskId}`;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function captureEventsWithTimestamps(stream, eventsFile, timedEventsFile) {
|
|
11
|
+
const events = fs.createWriteStream(eventsFile, { flags: 'a' });
|
|
12
|
+
const timedEvents = fs.createWriteStream(timedEventsFile, { flags: 'a' });
|
|
13
|
+
let buffer = '';
|
|
14
|
+
const writeLine = line => {
|
|
15
|
+
events.write(`${line}\n`);
|
|
16
|
+
const receivedAt = new Date().toISOString();
|
|
17
|
+
try {
|
|
18
|
+
timedEvents.write(`${JSON.stringify({ receivedAt, event: JSON.parse(line) })}\n`);
|
|
19
|
+
} catch {
|
|
20
|
+
timedEvents.write(`${JSON.stringify({ receivedAt, rawLine: line })}\n`);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
stream.setEncoding('utf8');
|
|
24
|
+
stream.on('data', chunk => {
|
|
25
|
+
buffer += chunk;
|
|
26
|
+
let newlineIndex;
|
|
27
|
+
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
|
|
28
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
29
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
30
|
+
if (line) writeLine(line);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
stream.on('end', () => {
|
|
34
|
+
if (buffer) writeLine(buffer.replace(/\r$/, ''));
|
|
35
|
+
events.end();
|
|
36
|
+
timedEvents.end();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
export function createHeadlessRunner({ codexBin = CODEX_BIN } = {}) {
|
|
11
41
|
const runningProcesses = new Map();
|
|
12
42
|
|
|
13
43
|
function startCodexTask({ runId, taskId, prompt, sandbox, cwd, outDir }) {
|
|
14
44
|
const events = path.join(outDir, 'events.jsonl');
|
|
45
|
+
const timedEvents = path.join(outDir, 'events_timed.jsonl');
|
|
15
46
|
const stderr = path.join(outDir, 'stderr.log');
|
|
16
47
|
const last = path.join(outDir, 'last_message.md');
|
|
17
48
|
fs.writeFileSync(path.join(outDir, 'prompt.md'), prompt);
|
|
18
49
|
const args = ['exec', '--json', '--sandbox', sandbox, '-C', cwd, '-o', last, prompt];
|
|
19
50
|
const child = spawn(codexBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
20
|
-
child.stdout
|
|
51
|
+
captureEventsWithTimestamps(child.stdout, events, timedEvents);
|
|
21
52
|
child.stderr.pipe(fs.createWriteStream(stderr, { flags: 'a' }));
|
|
22
53
|
const key = processKey(runId, taskId);
|
|
23
54
|
runningProcesses.set(key, child);
|
|
@@ -46,6 +46,7 @@ function shellQuote(value) {
|
|
|
46
46
|
|
|
47
47
|
const BIN_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin');
|
|
48
48
|
const FORMATTER_BIN = path.join(BIN_DIR, 'input-kanban-format-events.js');
|
|
49
|
+
const TIMESTAMP_BIN = path.join(BIN_DIR, 'input-kanban-timestamp-events.js');
|
|
49
50
|
const OVERVIEW_BIN = path.join(BIN_DIR, 'input-kanban-tmux-overview.js');
|
|
50
51
|
|
|
51
52
|
function buildOverviewCommand(runStatePath) {
|
|
@@ -54,7 +55,7 @@ function buildOverviewCommand(runStatePath) {
|
|
|
54
55
|
return `while true; do clear; node ${quotedOverviewBin} ${quotedStatePath}; sleep 2; done`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
58
|
+
function buildRunScript({ codexBin, formatterBin = FORMATTER_BIN, timestampBin = TIMESTAMP_BIN, sandbox, cwd, outDir, runId, taskId, role }) {
|
|
58
59
|
return `#!/usr/bin/env bash
|
|
59
60
|
set -u
|
|
60
61
|
|
|
@@ -67,15 +68,17 @@ TASK_ID=${shellQuote(taskId)}
|
|
|
67
68
|
ROLE=${shellQuote(role)}
|
|
68
69
|
PROMPT_FILE="$OUT_DIR/prompt.md"
|
|
69
70
|
EVENTS="$OUT_DIR/events.jsonl"
|
|
71
|
+
TIMED_EVENTS="$OUT_DIR/events_timed.jsonl"
|
|
70
72
|
STDERR_LOG="$OUT_DIR/stderr.log"
|
|
71
73
|
FORMATTER_BIN=${shellQuote(formatterBin)}
|
|
74
|
+
TIMESTAMP_BIN=${shellQuote(timestampBin)}
|
|
72
75
|
LAST_MESSAGE="$OUT_DIR/last_message.md"
|
|
73
76
|
EXIT_CODE="$OUT_DIR/exit_code"
|
|
74
77
|
|
|
75
78
|
cd "$CWD"
|
|
76
79
|
rm -f "$EXIT_CODE"
|
|
77
|
-
touch "$EVENTS" "$STDERR_LOG"
|
|
78
|
-
"$CODEX_BIN" exec --json --sandbox "$SANDBOX" -C "$CWD" -o "$LAST_MESSAGE" "$(<"$PROMPT_FILE")" > >(
|
|
80
|
+
touch "$EVENTS" "$TIMED_EVENTS" "$STDERR_LOG"
|
|
81
|
+
"$CODEX_BIN" 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)
|
|
79
82
|
code=$?
|
|
80
83
|
printf '%s' "$code" > "$EXIT_CODE"
|
|
81
84
|
printf '\\nInput Kanban tmux task completed.\\n'
|
package/src/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { CodexAppServerClient } from './appServerClient.js';
|
|
6
6
|
import { APP_ROOT, DEFAULT_REPO, RUNNER, RUNS_DIR } from './utils.js';
|
|
7
|
-
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
|
|
7
|
+
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun } from './orchestrator.js';
|
|
8
8
|
|
|
9
9
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
10
10
|
|
|
@@ -66,6 +66,10 @@ async function handleApi(req, res, url, appClient) {
|
|
|
66
66
|
const body = await readBody(req);
|
|
67
67
|
return send(res, 200, await archiveRun(runId, body));
|
|
68
68
|
}
|
|
69
|
+
if (parts.length === 4 && parts[3] === 'label' && req.method === 'PATCH') {
|
|
70
|
+
const body = await readBody(req);
|
|
71
|
+
return send(res, 200, await renameRun(runId, body));
|
|
72
|
+
}
|
|
69
73
|
if (parts.length === 4 && parts[3] === 'task-text' && req.method === 'GET') return send(res, 200, await readRunTaskText(runId), 'text/plain');
|
|
70
74
|
if (parts.length === 6 && parts[3] === 'tasks' && parts[5] === 'file' && req.method === 'GET') {
|
|
71
75
|
const text = await readRunFile(runId, parts[4], url.searchParams.get('name') || 'last_message.md');
|
|
@@ -78,7 +82,7 @@ async function handleApi(req, res, url, appClient) {
|
|
|
78
82
|
}
|
|
79
83
|
notFound(res);
|
|
80
84
|
} catch (e) {
|
|
81
|
-
send(res, 500, { error: e.message });
|
|
85
|
+
send(res, e.statusCode || 500, { error: e.message });
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|