input-kanban 0.0.3 → 0.0.5
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 +5 -2
- package/PROJECT_GUIDE.md +13 -5
- package/README.en.md +8 -4
- package/README.md +8 -4
- package/bin/input-kanban-format-events.js +12 -0
- package/bin/input-kanban-tmux-overview.js +66 -0
- package/package.json +2 -2
- package/public/assets/input-kanban-apple-touch-icon.png +0 -0
- package/public/assets/input-kanban-favicon-32.png +0 -0
- package/public/assets/input-kanban-icon.png +0 -0
- package/public/index.html +171 -98
- package/src/eventFormatter.js +85 -0
- package/src/orchestrator.js +73 -150
- package/src/runners/tmuxRunner.js +73 -23
- package/src/runners/tmuxUtils.js +3 -1
- package/src/server.js +1 -1
- package/src/tmux.js +17 -0
package/public/index.html
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
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="apple-touch-icon" sizes="180x180" href="/assets/input-kanban-apple-touch-icon.png" />
|
|
7
9
|
<style>
|
|
8
10
|
: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; }
|
|
9
11
|
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
|
10
12
|
header { padding: 18px 24px; background: #0a1020; border-bottom: 1px solid var(--line); box-shadow: 0 1px 0 rgba(255,255,255,.03); }
|
|
11
13
|
h1 { margin: 0; font-size: 22px; letter-spacing: .2px; }
|
|
14
|
+
.brand { display: inline-flex; align-items: center; gap: 10px; }
|
|
15
|
+
.brand-icon { width: 30px; height: 30px; border-radius: 9px; object-fit: cover; border: 1px solid rgba(148,163,184,.35); box-shadow: 0 1px 0 rgba(255,255,255,.08); }
|
|
12
16
|
h2 { margin: 0 0 12px; font-size: 24px; }
|
|
13
17
|
h3 { margin: 18px 0 8px; font-size: 15px; color: #cbd5e1; }
|
|
14
18
|
main { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 18px; padding: 18px; align-items: start; min-height: calc(100vh - 59px); }
|
|
@@ -30,22 +34,23 @@
|
|
|
30
34
|
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
31
35
|
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
32
36
|
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
33
|
-
th:nth-child(5), td:nth-child(5) { width:
|
|
34
|
-
th:nth-child(6), td:nth-child(6) { width:
|
|
35
|
-
th:nth-child(
|
|
36
|
-
th:nth-child(
|
|
37
|
-
th:nth-child(10), td:nth-child(10) { width: 94px; }
|
|
37
|
+
th:nth-child(5), td:nth-child(5) { width: 96px; }
|
|
38
|
+
th:nth-child(6), td:nth-child(6) { width: 92px; }
|
|
39
|
+
th:nth-child(7), td:nth-child(7) { width: 66px; }
|
|
40
|
+
th:nth-child(8), td:nth-child(8) { width: 94px; }
|
|
38
41
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
39
42
|
tr:hover { background: #162033; cursor: pointer; }
|
|
40
43
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
41
|
-
.attention-hint { display: block; margin-top: 6px; color: #fbbf24; font-size: 12px; font-weight: 700; white-space: normal; line-height: 1.35; }
|
|
42
|
-
.attention-hint code { color: #fde68a; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; word-break: break-all; }
|
|
43
44
|
.completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
|
|
44
45
|
.running, .planning, .judging { background: var(--blue); }
|
|
45
46
|
.failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
46
47
|
.plan_empty { background: var(--orange); }
|
|
47
48
|
.pending, .created { background: var(--gray); }
|
|
48
49
|
.batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
|
|
50
|
+
.batch-row-content { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
51
|
+
.batch-row-title { font-size: 15px; }
|
|
52
|
+
.batch-row-meta { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
53
|
+
.batch-row-meta .meta-chip { padding: 4px 7px; font-size: 11px; }
|
|
49
54
|
.job-row.selected { background: rgba(37,99,235,.16); box-shadow: inset 3px 0 0 #60a5fa; }
|
|
50
55
|
.task-name { min-width: 0; overflow: hidden; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
51
56
|
.task-role { display: block; margin-top: 2px; }
|
|
@@ -64,26 +69,30 @@
|
|
|
64
69
|
.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; }
|
|
65
70
|
.run-card-title .pill { flex: 0 0 auto; }
|
|
66
71
|
.run-card-id { margin-top: 5px; word-break: break-all; }
|
|
67
|
-
.run-card-
|
|
68
|
-
.run-card-
|
|
72
|
+
.run-card-meta { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
73
|
+
.run-card-meta .meta-chip { padding: 4px 7px; font-size: 11px; }
|
|
74
|
+
.run-card-meta .meta-chip.long .meta-value { max-width: 248px; }
|
|
69
75
|
.build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
|
|
70
76
|
.build-title { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 900; }
|
|
71
|
-
.build-meta { margin-top:
|
|
77
|
+
.build-meta { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
|
78
|
+
.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; }
|
|
79
|
+
.meta-chip.danger { border-color: #f97316; color: #fed7aa; background: rgba(180,83,9,.14); }
|
|
80
|
+
.meta-label { color: var(--muted); font-weight: 800; white-space: nowrap; }
|
|
81
|
+
.meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
|
+
.meta-chip.long .meta-value { max-width: min(680px, 72vw); }
|
|
83
|
+
.meta-chip .copy-btn { margin-left: 2px; }
|
|
72
84
|
.log-panel { margin-top: 16px; }
|
|
73
85
|
.file-tabs button { font-size: 13px; }
|
|
74
86
|
.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; }
|
|
75
87
|
.session-cell { word-break: break-all; }
|
|
88
|
+
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
89
|
+
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
90
|
+
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
76
91
|
.execution-summary { display: flex; flex-wrap: wrap; gap: 8px; 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; }
|
|
77
92
|
.execution-summary.hidden { display: none; }
|
|
78
93
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
79
94
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
80
95
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
81
|
-
.tmux-box { margin: 8px 0 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #020617; color: #cbd5e1; font-size: 12px; }
|
|
82
|
-
.tmux-box.hidden { display: none; }
|
|
83
|
-
.tmux-box-title { font-weight: 800; color: var(--text); margin-bottom: 5px; }
|
|
84
|
-
.tmux-actions { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
85
|
-
.tmux-actions button { margin: 0; }
|
|
86
|
-
.tmux-inline { display: block; margin-top: 3px; word-break: break-all; }
|
|
87
96
|
.file-content-wrap { position: relative; }
|
|
88
97
|
.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); }
|
|
89
98
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
@@ -91,7 +100,7 @@
|
|
|
91
100
|
</style>
|
|
92
101
|
</head>
|
|
93
102
|
<body>
|
|
94
|
-
<header><h1
|
|
103
|
+
<header><h1 class="brand"><img class="brand-icon" src="/assets/input-kanban-icon.png" alt="" aria-hidden="true" />Input 看板</h1></header>
|
|
95
104
|
<main>
|
|
96
105
|
<div class="sidebar">
|
|
97
106
|
<section>
|
|
@@ -110,6 +119,12 @@
|
|
|
110
119
|
<label>目标仓库</label><input id="repo" />
|
|
111
120
|
<label>运行目录</label><input id="runsDir" readonly />
|
|
112
121
|
<label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
|
|
122
|
+
<label>Worker 沙箱</label><select id="workerSandbox">
|
|
123
|
+
<option value="workspace-write" selected>workspace-write(默认,允许写当前仓库)</option>
|
|
124
|
+
<option value="read-only">read-only(只读)</option>
|
|
125
|
+
<option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
|
|
126
|
+
</select>
|
|
127
|
+
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。</div>
|
|
113
128
|
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
114
129
|
<div class="toolbar">
|
|
115
130
|
<button onclick="createRun()">创建批次</button>
|
|
@@ -121,7 +136,7 @@
|
|
|
121
136
|
<h2>批次详情</h2>
|
|
122
137
|
<div class="build-header">
|
|
123
138
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
124
|
-
<div id="autoRefreshHint" class="muted">自动刷新:未启动</div>
|
|
139
|
+
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
125
140
|
<div id="runNotice" class="notice warning hidden"></div>
|
|
126
141
|
<div class="toolbar">
|
|
127
142
|
<button onclick="planRun()">拆分任务</button>
|
|
@@ -140,20 +155,8 @@
|
|
|
140
155
|
<section id="filePanel" class="log-panel">
|
|
141
156
|
<h2>文件查看</h2>
|
|
142
157
|
<div id="fileTitle" class="muted">点击任务后选择文件</div>
|
|
143
|
-
<div class="toolbar file-tabs">
|
|
144
|
-
<button class="secondary" onclick="loadFile('prompt.md')">提示词</button>
|
|
145
|
-
<button class="secondary" onclick="loadFile('events.jsonl')">事件日志</button>
|
|
146
|
-
<button class="secondary" onclick="loadFile('events.pretty')">执行过程</button>
|
|
147
|
-
<button class="secondary" onclick="loadFile('stderr.log')">错误日志</button>
|
|
148
|
-
<button class="secondary" onclick="loadFile('last_message.md')">最终回复</button>
|
|
149
|
-
<button class="secondary" onclick="loadFile('exit_code')">退出码</button>
|
|
150
|
-
<button class="secondary" onclick="loadFile('result.json')">结果</button>
|
|
151
|
-
<button class="secondary" onclick="loadFile('evidence.json')">证据</button>
|
|
152
|
-
<button class="secondary" onclick="loadFile('judge_input.json')">验收输入</button>
|
|
153
|
-
<button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
|
|
154
|
-
</div>
|
|
158
|
+
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
155
159
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
156
|
-
<div id="tmuxPanel" class="tmux-box hidden"></div>
|
|
157
160
|
<div class="file-content-wrap">
|
|
158
161
|
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
|
|
159
162
|
<pre id="fileContent"></pre>
|
|
@@ -169,6 +172,7 @@ let currentState = null;
|
|
|
169
172
|
let lastAutoRefreshAt = null;
|
|
170
173
|
let runListVisibleCount = 10;
|
|
171
174
|
let latestRuns = [];
|
|
175
|
+
const statusByRunId = new Map();
|
|
172
176
|
const AUTO_REFRESH_MS = 3000;
|
|
173
177
|
const RUN_LIST_PAGE_SIZE = 10;
|
|
174
178
|
|
|
@@ -201,7 +205,31 @@ function durationSeconds(start, end) {
|
|
|
201
205
|
const endMs = end ? new Date(end).getTime() : Date.now();
|
|
202
206
|
return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
|
|
203
207
|
}
|
|
204
|
-
function
|
|
208
|
+
function maxIsoTime(values) {
|
|
209
|
+
const times = values.map(value => Date.parse(value || '')).filter(Number.isFinite);
|
|
210
|
+
if (!times.length) return null;
|
|
211
|
+
return new Date(Math.max(...times)).toISOString();
|
|
212
|
+
}
|
|
213
|
+
function runDurationEnd(s) {
|
|
214
|
+
if (!s) return null;
|
|
215
|
+
const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
|
|
216
|
+
if (!terminalStatuses.has(s.status)) return null;
|
|
217
|
+
return maxIsoTime([
|
|
218
|
+
s.stoppedAt,
|
|
219
|
+
s.stopInfo?.stoppedAt,
|
|
220
|
+
s.judge?.endedAt,
|
|
221
|
+
s.planner?.endedAt,
|
|
222
|
+
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
223
|
+
]) || s.updatedAt;
|
|
224
|
+
}
|
|
225
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))} 秒`; }
|
|
226
|
+
function basenamePath(value) {
|
|
227
|
+
const parts = String(value || '').split(/[\\/]/).filter(Boolean);
|
|
228
|
+
return parts.at(-1) || value || '-';
|
|
229
|
+
}
|
|
230
|
+
function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
|
|
231
|
+
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
|
+
}
|
|
205
233
|
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
206
234
|
function taskById(id) {
|
|
207
235
|
if (!currentState) return null;
|
|
@@ -209,6 +237,26 @@ function taskById(id) {
|
|
|
209
237
|
if (id === 'judge') return currentState.judge;
|
|
210
238
|
return (currentState.tasks || []).find(t => t.id === id) || null;
|
|
211
239
|
}
|
|
240
|
+
function tmuxSessionName(state = currentState) {
|
|
241
|
+
return state?.tmux?.tmuxSessionName || state?.tmux?.sessionName || (state?.runId ? `input-kanban-${state.runId}` : '');
|
|
242
|
+
}
|
|
243
|
+
function hasRunTmuxMetadata(state = currentState) {
|
|
244
|
+
if (!state || state.runner !== 'tmux') return false;
|
|
245
|
+
if (state.tmux?.hasTmuxSession || state.tmux?.tmuxSessionName || state.tmux?.tmuxAttachCommand) return true;
|
|
246
|
+
const roles = [state.planner, ...(state.tasks || []), state.judge];
|
|
247
|
+
return roles.some(role => role?.tmux?.ready === true && (role.tmux.sessionName || role.tmux.windowName || role.tmux.target));
|
|
248
|
+
}
|
|
249
|
+
function runAttachCommand(state = currentState) {
|
|
250
|
+
if (!hasRunTmuxMetadata(state)) return '';
|
|
251
|
+
return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
|
|
252
|
+
}
|
|
253
|
+
function runListHasTmuxMetadata(run) {
|
|
254
|
+
if (run?.runner !== 'tmux') return false;
|
|
255
|
+
if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
|
|
256
|
+
const cached = statusByRunId.get(run.runId);
|
|
257
|
+
if (cached) return hasRunTmuxMetadata(cached);
|
|
258
|
+
return !!(run?.tmux?.hasTmuxSession || run?.tmux?.tmuxSessionName || run?.tmux?.tmuxAttachCommand);
|
|
259
|
+
}
|
|
212
260
|
|
|
213
261
|
async function loadHealth() {
|
|
214
262
|
const h = await api('/api/health');
|
|
@@ -228,7 +276,7 @@ function hideCreateForm() {
|
|
|
228
276
|
document.getElementById('filePanel').classList.remove('hidden');
|
|
229
277
|
}
|
|
230
278
|
async function createRun() {
|
|
231
|
-
const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, taskText: taskText.value };
|
|
279
|
+
const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
|
|
232
280
|
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
233
281
|
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
234
282
|
clearFileView();
|
|
@@ -244,10 +292,14 @@ function renderRunList() {
|
|
|
244
292
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
245
293
|
const cards = visibleRuns.map(r => `
|
|
246
294
|
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
|
|
247
|
-
<div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span>${pill(r.status)}</div>
|
|
248
|
-
<div class="run-card-
|
|
249
|
-
|
|
250
|
-
|
|
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>
|
|
296
|
+
<div class="run-card-meta">
|
|
297
|
+
${metaChip('Run ID', r.runId, { long: true })}
|
|
298
|
+
${metaChip('创建', formatDateTime(r.createdAt))}
|
|
299
|
+
${metaChip('进度', `${r.completed}/${r.total}`)}
|
|
300
|
+
${metaChip('执行中', r.running)}
|
|
301
|
+
${metaChip('失败', r.failed, { danger: Number(r.failed) > 0 })}
|
|
302
|
+
</div>
|
|
251
303
|
</div>`);
|
|
252
304
|
if (latestRuns.length > runListVisibleCount) {
|
|
253
305
|
cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()">查看更多(${runListVisibleCount}/${latestRuns.length})</button>`);
|
|
@@ -262,14 +314,39 @@ async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFi
|
|
|
262
314
|
async function refreshSelected({auto=false} = {}) {
|
|
263
315
|
if (!selectedRun) return;
|
|
264
316
|
currentState = await api(`/api/runs/${selectedRun}/status`);
|
|
317
|
+
statusByRunId.set(selectedRun, currentState);
|
|
265
318
|
if (auto) lastAutoRefreshAt = new Date();
|
|
266
|
-
document.getElementById('selected').innerHTML =
|
|
319
|
+
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
267
320
|
updateAutoRefreshHint();
|
|
268
321
|
updateRunNotice();
|
|
269
322
|
await loadTaskDescription();
|
|
270
323
|
renderTasks(); await refreshRuns();
|
|
271
324
|
if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
|
|
272
325
|
}
|
|
326
|
+
function renderSelectedHeader() {
|
|
327
|
+
if (!currentState) return '<div class="muted">未选择任务批次</div>';
|
|
328
|
+
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
329
|
+
const chips = [
|
|
330
|
+
metaChip('Run ID', currentState.runId, { long: true }),
|
|
331
|
+
metaChip('仓库', basenamePath(currentState.repo), { title: currentState.repo }),
|
|
332
|
+
metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
|
|
333
|
+
metaChip('开始', formatDateTime(currentState.createdAt)),
|
|
334
|
+
metaChip('用时', `${durationSeconds(currentState.createdAt, runDurationEnd(currentState))} 秒`)
|
|
335
|
+
];
|
|
336
|
+
if (currentState.runner === 'tmux') {
|
|
337
|
+
if (hasRunTmuxMetadata(currentState)) {
|
|
338
|
+
chips.push(metaChip('终端', tmuxSessionName(currentState), {
|
|
339
|
+
long: true,
|
|
340
|
+
extra: `<button class="secondary copy-btn" onclick="copyTmuxRunCommand(event)">复制tmux attach指令</button>`
|
|
341
|
+
}));
|
|
342
|
+
} else {
|
|
343
|
+
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
|
|
347
|
+
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>`;
|
|
349
|
+
}
|
|
273
350
|
async function loadTaskDescription() {
|
|
274
351
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
275
352
|
document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
|
|
@@ -307,16 +384,9 @@ function updateAutoRefreshHint() {
|
|
|
307
384
|
function taskStatusCell(t) {
|
|
308
385
|
if (t?.manualCompletion) {
|
|
309
386
|
const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
|
|
310
|
-
return
|
|
387
|
+
return `<span class="status-stack">${pill(original)}<span class="pill completed">手动标记成功已完成</span></span>`;
|
|
311
388
|
}
|
|
312
|
-
|
|
313
|
-
return `${pill(t?.status)}${hint}`;
|
|
314
|
-
}
|
|
315
|
-
function attentionHintCell(t) {
|
|
316
|
-
if (!t?.attentionHint) return '';
|
|
317
|
-
const command = t.attentionHint.attachCommand ? ` <code>${esc(t.attentionHint.attachCommand)}</code>` : '';
|
|
318
|
-
const reasons = Array.isArray(t.attentionHint.reasons) && t.attentionHint.reasons.length ? `|${esc(t.attentionHint.reasons.join(' / '))}` : '';
|
|
319
|
-
return `<span class="attention-hint" title="${esc(t.attentionHint.message || '')}">可能需要人工介入;请 attach tmux 检查。${command}${reasons}</span>`;
|
|
389
|
+
return pill(t?.status);
|
|
320
390
|
}
|
|
321
391
|
function taskActionCell(id, t) {
|
|
322
392
|
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
@@ -324,16 +394,13 @@ function taskActionCell(id, t) {
|
|
|
324
394
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
325
395
|
return `<button class="danger" onclick="markTaskCompleted(event, '${id}')">手动标记成功</button>`;
|
|
326
396
|
}
|
|
397
|
+
function shortSessionId(thread) {
|
|
398
|
+
const text = String(thread || '');
|
|
399
|
+
return text.length > 8 ? text.slice(-8) : text;
|
|
400
|
+
}
|
|
327
401
|
function sessionCell(thread) {
|
|
328
402
|
if (!thread) return '-';
|
|
329
|
-
return `<span class="session-cell"
|
|
330
|
-
}
|
|
331
|
-
function tmuxCell(t) {
|
|
332
|
-
if (!isTmuxMode()) return '-';
|
|
333
|
-
const tmux = t?.tmux;
|
|
334
|
-
if (!tmux) return '<span class="muted">未启动终端</span>';
|
|
335
|
-
const label = tmux.windowName || tmux.target || 'tmux';
|
|
336
|
-
return `<span class="session-cell" title="${esc(tmux.target || '')}">${esc(label)}</span>`;
|
|
403
|
+
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>`;
|
|
337
404
|
}
|
|
338
405
|
function taskStartedCell(t) {
|
|
339
406
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
@@ -343,29 +410,67 @@ function taskDurationCell(t) {
|
|
|
343
410
|
const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
|
|
344
411
|
return `${durationSeconds(t.startedAt, end)} 秒`;
|
|
345
412
|
}
|
|
413
|
+
function processExitCell(t) {
|
|
414
|
+
return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
|
|
415
|
+
}
|
|
416
|
+
function taskActionInfoCell(id, t) {
|
|
417
|
+
return `<div class="row-actions">${taskActionCell(id, t)}</div>`;
|
|
418
|
+
}
|
|
346
419
|
function taskRow(id, role, t) {
|
|
347
420
|
const thread = t?.codexThread?.id || '';
|
|
348
|
-
return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${
|
|
421
|
+
return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${processExitCell(t)}</td><td>${sessionCell(thread)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionInfoCell(id, t)}</td></tr>`;
|
|
349
422
|
}
|
|
350
423
|
function renderTasks() {
|
|
351
424
|
const s = currentState;
|
|
352
425
|
const rows = [taskRow('planner','planner',s.planner)];
|
|
426
|
+
const columnCount = 8;
|
|
353
427
|
if (Array.isArray(s.batches) && s.batches.length) {
|
|
354
428
|
for (const b of s.batches) {
|
|
355
429
|
const done = (b.tasks || []).filter(t => t.status === 'completed').length;
|
|
356
|
-
rows.push(`<tr class="batch-row"><td colspan="
|
|
430
|
+
rows.push(`<tr class="batch-row"><td colspan="${columnCount}"><div class="batch-row-content"><span class="batch-row-title">${esc(b.name || b.id)}</span>${pill(b.status)}<span class="batch-row-meta">${metaChip('Batch ID', b.id)}${metaChip('最大并发', b.maxParallel || '-')}${metaChip('进度', `${done}/${(b.tasks || []).length}`)}</span></div></td></tr>`);
|
|
357
431
|
for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
|
|
358
432
|
}
|
|
359
433
|
} else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
|
|
360
434
|
rows.push(taskRow('judge','最终验收',s.judge));
|
|
361
|
-
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th
|
|
435
|
+
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号/退出码</th><th>Codex 会话ID</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
|
|
436
|
+
}
|
|
437
|
+
const FILE_TAB_SETS = {
|
|
438
|
+
planner: [
|
|
439
|
+
['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
|
|
440
|
+
],
|
|
441
|
+
worker: [
|
|
442
|
+
['events.pretty', '执行过程'], ['last_message.md', '最终回复'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['result.json', '结果'], ['evidence.json', '证据'], ['events.jsonl', '事件日志']
|
|
443
|
+
],
|
|
444
|
+
judge: [
|
|
445
|
+
['verdict.json', '验收结论'], ['judge_input.json', '验收输入'], ['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
|
|
446
|
+
]
|
|
447
|
+
};
|
|
448
|
+
function roleForSelectedTask() {
|
|
449
|
+
if (selectedTask === 'planner') return 'planner';
|
|
450
|
+
if (selectedTask === 'judge') return 'judge';
|
|
451
|
+
return selectedTask ? 'worker' : 'worker';
|
|
452
|
+
}
|
|
453
|
+
function fileTabsForSelectedTask() {
|
|
454
|
+
return FILE_TAB_SETS[roleForSelectedTask()] || FILE_TAB_SETS.worker;
|
|
455
|
+
}
|
|
456
|
+
function renderFileTabs() {
|
|
457
|
+
const el = document.getElementById('fileTabs');
|
|
458
|
+
if (!el) return;
|
|
459
|
+
if (!selectedTask) { el.innerHTML = ''; return; }
|
|
460
|
+
el.innerHTML = fileTabsForSelectedTask().map(([name, label]) => `<button class="secondary" onclick="loadFile('${name}')">${esc(label)}</button>`).join('');
|
|
362
461
|
}
|
|
363
462
|
async function selectTask(id) {
|
|
364
463
|
selectedTask = id;
|
|
365
464
|
document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
|
|
465
|
+
renderFileTabs();
|
|
366
466
|
renderTasks();
|
|
367
|
-
if (selectedFileName) await loadFile(selectedFileName);
|
|
368
|
-
else
|
|
467
|
+
if (selectedFileName && fileTabsForSelectedTask().some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
|
|
468
|
+
else {
|
|
469
|
+
selectedFileName = null;
|
|
470
|
+
document.getElementById('fileContent').textContent = '';
|
|
471
|
+
hideExecutionSummary();
|
|
472
|
+
updateCopyLastMessageButton();
|
|
473
|
+
}
|
|
369
474
|
}
|
|
370
475
|
async function loadFile(name, { preserveScroll = false } = {}) {
|
|
371
476
|
if (!selectedRun || !selectedTask) return;
|
|
@@ -378,14 +483,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
378
483
|
else pre.scrollTop = 0;
|
|
379
484
|
if (name === 'events.pretty') await renderExecutionSummary();
|
|
380
485
|
else hideExecutionSummary();
|
|
381
|
-
renderTmuxPanel();
|
|
382
486
|
updateCopyLastMessageButton();
|
|
383
487
|
}
|
|
384
488
|
function clearFileView() {
|
|
385
489
|
document.getElementById('fileTitle').textContent = '点击任务后选择文件';
|
|
386
490
|
document.getElementById('fileContent').textContent = '';
|
|
491
|
+
const tabs = document.getElementById('fileTabs');
|
|
492
|
+
if (tabs) tabs.innerHTML = '';
|
|
387
493
|
hideExecutionSummary();
|
|
388
|
-
hideTmuxPanel();
|
|
389
494
|
updateCopyLastMessageButton();
|
|
390
495
|
}
|
|
391
496
|
function updateCopyLastMessageButton() {
|
|
@@ -411,48 +516,16 @@ function hideExecutionSummary() {
|
|
|
411
516
|
el.classList.add('hidden');
|
|
412
517
|
el.innerHTML = '';
|
|
413
518
|
}
|
|
414
|
-
function
|
|
415
|
-
const el = document.getElementById('tmuxPanel');
|
|
416
|
-
el.classList.add('hidden');
|
|
417
|
-
el.innerHTML = '';
|
|
418
|
-
}
|
|
419
|
-
function renderTmuxPanel() {
|
|
420
|
-
const el = document.getElementById('tmuxPanel');
|
|
421
|
-
if (!el) return;
|
|
422
|
-
if (!selectedTask || !currentState) { hideTmuxPanel(); return; }
|
|
423
|
-
if (!isTmuxMode()) {
|
|
424
|
-
el.classList.remove('hidden');
|
|
425
|
-
el.innerHTML = '<div class="tmux-box-title">终端模式</div><span class="muted">当前 runner 为 headless,无需终端附加操作。</span>';
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
const tmux = taskById(selectedTask)?.tmux;
|
|
429
|
-
if (!tmux) {
|
|
430
|
-
el.classList.remove('hidden');
|
|
431
|
-
el.innerHTML = '<div class="tmux-box-title">tmux 终端</div><span class="muted">该任务尚未生成 tmux window。</span>';
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
el.classList.remove('hidden');
|
|
435
|
-
el.innerHTML = `
|
|
436
|
-
<div class="tmux-box-title">tmux 终端</div>
|
|
437
|
-
<span class="tmux-inline">session:${esc(tmux.sessionName || '-')}</span>
|
|
438
|
-
<span class="tmux-inline">window:${esc(tmux.windowName || '-')}</span>
|
|
439
|
-
<span class="tmux-inline">target:${esc(tmux.target || '-')}</span>
|
|
440
|
-
<div class="tmux-actions">
|
|
441
|
-
${tmux.attachCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'attach')">复制 attach</button>` : ''}
|
|
442
|
-
${tmux.selectWindowCommand ? `<button class="secondary" onclick="copyTmuxCommand(event, 'select')">复制 select-window</button>` : ''}
|
|
443
|
-
</div>`;
|
|
444
|
-
}
|
|
445
|
-
async function copyTmuxCommand(event, kind) {
|
|
519
|
+
async function copyTmuxRunCommand(event) {
|
|
446
520
|
event.stopPropagation();
|
|
447
|
-
const
|
|
448
|
-
const command = kind === 'attach' ? tmux?.attachCommand : tmux?.selectWindowCommand;
|
|
521
|
+
const command = runAttachCommand(currentState);
|
|
449
522
|
if (!command) return;
|
|
450
523
|
try {
|
|
451
524
|
await navigator.clipboard.writeText(command);
|
|
452
525
|
event.currentTarget.textContent = '已复制';
|
|
453
|
-
setTimeout(() => { event.currentTarget.textContent =
|
|
526
|
+
setTimeout(() => { event.currentTarget.textContent = '复制tmux attach指令'; }, 900);
|
|
454
527
|
} catch {
|
|
455
|
-
prompt(
|
|
528
|
+
prompt('复制 tmux attach 命令', command);
|
|
456
529
|
}
|
|
457
530
|
}
|
|
458
531
|
async function renderExecutionSummary() {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
function formatKnownFields(obj, fields) {
|
|
2
|
+
return fields
|
|
3
|
+
.filter(field => obj[field] !== undefined && obj[field] !== null)
|
|
4
|
+
.map(field => ` ${field}: ${typeof obj[field] === 'string' ? obj[field] : JSON.stringify(obj[field], null, 2)}`)
|
|
5
|
+
.join('\n');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatJson(value) { return indentText(JSON.stringify(value, null, 2)); }
|
|
9
|
+
function indentText(text) { return String(text).split('\n').map(line => ` ${line}`).join('\n'); }
|
|
10
|
+
function truncateText(text, max = 12000) { return text.length > max ? `${text.slice(0, max)}\n...<已截断 ${text.length - max} 字符>` : text; }
|
|
11
|
+
|
|
12
|
+
function displayItemType(type) {
|
|
13
|
+
return {
|
|
14
|
+
command_execution: '命令执行',
|
|
15
|
+
agent_message: '模型回复',
|
|
16
|
+
agentMessage: '模型回复',
|
|
17
|
+
reasoning: '推理',
|
|
18
|
+
file_change: '文件变更',
|
|
19
|
+
fileChange: '文件变更',
|
|
20
|
+
mcp_tool_call: 'MCP 工具调用',
|
|
21
|
+
mcpToolCall: 'MCP 工具调用'
|
|
22
|
+
}[type] || type;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatCodexItem(seq, action, item = {}) {
|
|
26
|
+
const type = item.type || 'unknown';
|
|
27
|
+
const title = `[${seq}] ${action}: ${displayItemType(type)}`;
|
|
28
|
+
if (type === 'command_execution') {
|
|
29
|
+
const parts = [title];
|
|
30
|
+
if (item.command) parts.push(` 命令: ${item.command}`);
|
|
31
|
+
if (item.status) parts.push(` 状态: ${item.status}`);
|
|
32
|
+
if (item.exit_code !== undefined && item.exit_code !== null) parts.push(` 退出码: ${item.exit_code}`);
|
|
33
|
+
if (item.aggregated_output) parts.push(` 输出:\n${indentText(truncateText(item.aggregated_output))}`);
|
|
34
|
+
return parts.join('\n');
|
|
35
|
+
}
|
|
36
|
+
if (type === 'agent_message' || type === 'agentMessage') {
|
|
37
|
+
const text = item.text || item.message || item.content || '';
|
|
38
|
+
return text ? `${title}\n 内容:\n${indentText(truncateText(String(text)))}` : title;
|
|
39
|
+
}
|
|
40
|
+
if (type === 'reasoning') {
|
|
41
|
+
const summary = item.summary || item.content || '';
|
|
42
|
+
return summary ? `${title}\n 摘要:\n${indentText(truncateText(Array.isArray(summary) ? summary.join('\n') : String(summary)))}` : title;
|
|
43
|
+
}
|
|
44
|
+
if (type === 'file_change' || type === 'fileChange') {
|
|
45
|
+
return `${title}\n${formatKnownFields(item, ['status', 'path', 'changes'])}`.trimEnd();
|
|
46
|
+
}
|
|
47
|
+
return `${title}\n${formatJson(item)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatCodexEvent(seq, event) {
|
|
51
|
+
switch (event.type) {
|
|
52
|
+
case 'thread.started':
|
|
53
|
+
return `[${seq}] Codex 会话开始\n 会话ID: ${event.thread_id || '-'}`;
|
|
54
|
+
case 'turn.started':
|
|
55
|
+
return `[${seq}] 回合开始`;
|
|
56
|
+
case 'turn.completed':
|
|
57
|
+
return `[${seq}] 回合完成\n${formatKnownFields(event, ['status', 'error', 'usage'])}`.trimEnd();
|
|
58
|
+
case 'item.started':
|
|
59
|
+
return formatCodexItem(seq, '开始', event.item);
|
|
60
|
+
case 'item.completed':
|
|
61
|
+
return formatCodexItem(seq, '完成', event.item);
|
|
62
|
+
case 'error':
|
|
63
|
+
return `[${seq}] 错误\n${formatJson(event)}`;
|
|
64
|
+
default:
|
|
65
|
+
return `[${seq}] ${event.type || '未知事件'}\n${formatJson(event)}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatCodexEventsJsonl(text) {
|
|
70
|
+
if (!text.trim()) return '暂无事件日志。';
|
|
71
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
72
|
+
return lines.map((line, index) => {
|
|
73
|
+
const seq = String(index + 1).padStart(3, '0');
|
|
74
|
+
let event;
|
|
75
|
+
try { event = JSON.parse(line); }
|
|
76
|
+
catch { return `[${seq}] 无法解析事件\n${line}`; }
|
|
77
|
+
return formatCodexEvent(seq, event);
|
|
78
|
+
}).join('\n\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatCodexEventLine(line, index = 0) {
|
|
82
|
+
const seq = String(index + 1).padStart(3, '0');
|
|
83
|
+
try { return formatCodexEvent(seq, JSON.parse(line)); }
|
|
84
|
+
catch { return `[${seq}] 无法解析事件\n${line}`; }
|
|
85
|
+
}
|