input-kanban 0.0.2 → 0.0.4
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 +10 -1
- package/PROJECT_GUIDE.md +36 -4
- package/README.en.md +13 -1
- package/README.md +13 -1
- package/bin/input-kanban-format-events.js +12 -0
- package/bin/input-kanban-tmux-overview.js +66 -0
- package/bin/input-kanban.js +12 -1
- package/package.json +2 -2
- package/public/index.html +166 -29
- package/src/eventFormatter.js +85 -0
- package/src/orchestrator.js +139 -131
- package/src/runners/headlessRunner.js +51 -0
- package/src/runners/index.js +13 -0
- package/src/runners/tmuxRunner.js +220 -0
- package/src/runners/tmuxUtils.js +17 -0
- package/src/server.js +3 -3
- package/src/tmux.js +156 -0
- package/src/utils.js +9 -0
package/public/index.html
CHANGED
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
31
31
|
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
32
32
|
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(
|
|
33
|
+
th:nth-child(5), td:nth-child(5) { width: 96px; }
|
|
34
|
+
th:nth-child(6), td:nth-child(6) { width: 92px; }
|
|
35
|
+
th:nth-child(7), td:nth-child(7) { width: 66px; }
|
|
36
|
+
th:nth-child(8), td:nth-child(8) { width: 94px; }
|
|
37
37
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
38
38
|
tr:hover { background: #162033; cursor: pointer; }
|
|
39
39
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
@@ -65,11 +65,20 @@
|
|
|
65
65
|
.run-card-progress { margin-top: 8px; display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
|
66
66
|
.build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
|
|
67
67
|
.build-title { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 900; }
|
|
68
|
-
.build-meta { margin-top:
|
|
68
|
+
.build-meta { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
|
|
69
|
+
.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; }
|
|
70
|
+
.meta-chip.danger { border-color: #f97316; color: #fed7aa; background: rgba(180,83,9,.14); }
|
|
71
|
+
.meta-label { color: var(--muted); font-weight: 800; white-space: nowrap; }
|
|
72
|
+
.meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
73
|
+
.meta-chip.long .meta-value { max-width: min(680px, 72vw); }
|
|
74
|
+
.meta-chip .copy-btn { margin-left: 2px; }
|
|
69
75
|
.log-panel { margin-top: 16px; }
|
|
70
76
|
.file-tabs button { font-size: 13px; }
|
|
71
77
|
.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; }
|
|
72
78
|
.session-cell { word-break: break-all; }
|
|
79
|
+
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
80
|
+
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
81
|
+
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
73
82
|
.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; }
|
|
74
83
|
.execution-summary.hidden { display: none; }
|
|
75
84
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
@@ -101,6 +110,12 @@
|
|
|
101
110
|
<label>目标仓库</label><input id="repo" />
|
|
102
111
|
<label>运行目录</label><input id="runsDir" readonly />
|
|
103
112
|
<label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
|
|
113
|
+
<label>Worker 沙箱</label><select id="workerSandbox">
|
|
114
|
+
<option value="workspace-write" selected>workspace-write(默认,允许写当前仓库)</option>
|
|
115
|
+
<option value="read-only">read-only(只读)</option>
|
|
116
|
+
<option value="danger-full-access">danger-full-access(高风险,跳过沙箱限制)</option>
|
|
117
|
+
</select>
|
|
118
|
+
<div class="muted">仅影响 worker;任务拆分和汇总验收仍保持 read-only。</div>
|
|
104
119
|
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
105
120
|
<div class="toolbar">
|
|
106
121
|
<button onclick="createRun()">创建批次</button>
|
|
@@ -112,7 +127,7 @@
|
|
|
112
127
|
<h2>批次详情</h2>
|
|
113
128
|
<div class="build-header">
|
|
114
129
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
115
|
-
<div id="autoRefreshHint" class="muted">自动刷新:未启动</div>
|
|
130
|
+
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
116
131
|
<div id="runNotice" class="notice warning hidden"></div>
|
|
117
132
|
<div class="toolbar">
|
|
118
133
|
<button onclick="planRun()">拆分任务</button>
|
|
@@ -131,18 +146,7 @@
|
|
|
131
146
|
<section id="filePanel" class="log-panel">
|
|
132
147
|
<h2>文件查看</h2>
|
|
133
148
|
<div id="fileTitle" class="muted">点击任务后选择文件</div>
|
|
134
|
-
<div class="toolbar file-tabs">
|
|
135
|
-
<button class="secondary" onclick="loadFile('prompt.md')">提示词</button>
|
|
136
|
-
<button class="secondary" onclick="loadFile('events.jsonl')">事件日志</button>
|
|
137
|
-
<button class="secondary" onclick="loadFile('events.pretty')">执行过程</button>
|
|
138
|
-
<button class="secondary" onclick="loadFile('stderr.log')">错误日志</button>
|
|
139
|
-
<button class="secondary" onclick="loadFile('last_message.md')">最终回复</button>
|
|
140
|
-
<button class="secondary" onclick="loadFile('exit_code')">退出码</button>
|
|
141
|
-
<button class="secondary" onclick="loadFile('result.json')">结果</button>
|
|
142
|
-
<button class="secondary" onclick="loadFile('evidence.json')">证据</button>
|
|
143
|
-
<button class="secondary" onclick="loadFile('judge_input.json')">验收输入</button>
|
|
144
|
-
<button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
|
|
145
|
-
</div>
|
|
149
|
+
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
146
150
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
147
151
|
<div class="file-content-wrap">
|
|
148
152
|
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
|
|
@@ -159,6 +163,7 @@ let currentState = null;
|
|
|
159
163
|
let lastAutoRefreshAt = null;
|
|
160
164
|
let runListVisibleCount = 10;
|
|
161
165
|
let latestRuns = [];
|
|
166
|
+
const statusByRunId = new Map();
|
|
162
167
|
const AUTO_REFRESH_MS = 3000;
|
|
163
168
|
const RUN_LIST_PAGE_SIZE = 10;
|
|
164
169
|
|
|
@@ -191,7 +196,58 @@ function durationSeconds(start, end) {
|
|
|
191
196
|
const endMs = end ? new Date(end).getTime() : Date.now();
|
|
192
197
|
return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
|
|
193
198
|
}
|
|
194
|
-
function
|
|
199
|
+
function maxIsoTime(values) {
|
|
200
|
+
const times = values.map(value => Date.parse(value || '')).filter(Number.isFinite);
|
|
201
|
+
if (!times.length) return null;
|
|
202
|
+
return new Date(Math.max(...times)).toISOString();
|
|
203
|
+
}
|
|
204
|
+
function runDurationEnd(s) {
|
|
205
|
+
if (!s) return null;
|
|
206
|
+
const terminalStatuses = new Set(['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped']);
|
|
207
|
+
if (!terminalStatuses.has(s.status)) return null;
|
|
208
|
+
return maxIsoTime([
|
|
209
|
+
s.stoppedAt,
|
|
210
|
+
s.stopInfo?.stoppedAt,
|
|
211
|
+
s.judge?.endedAt,
|
|
212
|
+
s.planner?.endedAt,
|
|
213
|
+
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
214
|
+
]) || s.updatedAt;
|
|
215
|
+
}
|
|
216
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))} 秒`; }
|
|
217
|
+
function basenamePath(value) {
|
|
218
|
+
const parts = String(value || '').split(/[\\/]/).filter(Boolean);
|
|
219
|
+
return parts.at(-1) || value || '-';
|
|
220
|
+
}
|
|
221
|
+
function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
|
|
222
|
+
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>`;
|
|
223
|
+
}
|
|
224
|
+
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
225
|
+
function taskById(id) {
|
|
226
|
+
if (!currentState) return null;
|
|
227
|
+
if (id === 'planner') return currentState.planner;
|
|
228
|
+
if (id === 'judge') return currentState.judge;
|
|
229
|
+
return (currentState.tasks || []).find(t => t.id === id) || null;
|
|
230
|
+
}
|
|
231
|
+
function tmuxSessionName(state = currentState) {
|
|
232
|
+
return state?.tmux?.tmuxSessionName || state?.tmux?.sessionName || (state?.runId ? `input-kanban-${state.runId}` : '');
|
|
233
|
+
}
|
|
234
|
+
function hasRunTmuxMetadata(state = currentState) {
|
|
235
|
+
if (!state || state.runner !== 'tmux') return false;
|
|
236
|
+
if (state.tmux?.hasTmuxSession || state.tmux?.tmuxSessionName || state.tmux?.tmuxAttachCommand) return true;
|
|
237
|
+
const roles = [state.planner, ...(state.tasks || []), state.judge];
|
|
238
|
+
return roles.some(role => role?.tmux?.ready === true && (role.tmux.sessionName || role.tmux.windowName || role.tmux.target));
|
|
239
|
+
}
|
|
240
|
+
function runAttachCommand(state = currentState) {
|
|
241
|
+
if (!hasRunTmuxMetadata(state)) return '';
|
|
242
|
+
return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
|
|
243
|
+
}
|
|
244
|
+
function runListHasTmuxMetadata(run) {
|
|
245
|
+
if (run?.runner !== 'tmux') return false;
|
|
246
|
+
if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
|
|
247
|
+
const cached = statusByRunId.get(run.runId);
|
|
248
|
+
if (cached) return hasRunTmuxMetadata(cached);
|
|
249
|
+
return !!(run?.tmux?.hasTmuxSession || run?.tmux?.tmuxSessionName || run?.tmux?.tmuxAttachCommand);
|
|
250
|
+
}
|
|
195
251
|
|
|
196
252
|
async function loadHealth() {
|
|
197
253
|
const h = await api('/api/health');
|
|
@@ -211,7 +267,7 @@ function hideCreateForm() {
|
|
|
211
267
|
document.getElementById('filePanel').classList.remove('hidden');
|
|
212
268
|
}
|
|
213
269
|
async function createRun() {
|
|
214
|
-
const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, taskText: taskText.value };
|
|
270
|
+
const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, workerSandbox: workerSandbox.value, taskText: taskText.value };
|
|
215
271
|
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
216
272
|
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
217
273
|
clearFileView();
|
|
@@ -227,7 +283,7 @@ function renderRunList() {
|
|
|
227
283
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
228
284
|
const cards = visibleRuns.map(r => `
|
|
229
285
|
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
|
|
230
|
-
<div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span>${pill(r.status)}</div>
|
|
286
|
+
<div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span>${pill(r.status)}</span></div>
|
|
231
287
|
<div class="run-card-id muted">${esc(r.runId)}</div>
|
|
232
288
|
<div class="run-card-created">创建 ${esc(formatDateTime(r.createdAt))}</div>
|
|
233
289
|
<div class="run-card-progress"><span>进度 ${r.completed}/${r.total}</span><span>执行中 ${r.running}|失败 ${r.failed}</span></div>
|
|
@@ -245,14 +301,39 @@ async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFi
|
|
|
245
301
|
async function refreshSelected({auto=false} = {}) {
|
|
246
302
|
if (!selectedRun) return;
|
|
247
303
|
currentState = await api(`/api/runs/${selectedRun}/status`);
|
|
304
|
+
statusByRunId.set(selectedRun, currentState);
|
|
248
305
|
if (auto) lastAutoRefreshAt = new Date();
|
|
249
|
-
document.getElementById('selected').innerHTML =
|
|
306
|
+
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
250
307
|
updateAutoRefreshHint();
|
|
251
308
|
updateRunNotice();
|
|
252
309
|
await loadTaskDescription();
|
|
253
310
|
renderTasks(); await refreshRuns();
|
|
254
311
|
if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
|
|
255
312
|
}
|
|
313
|
+
function renderSelectedHeader() {
|
|
314
|
+
if (!currentState) return '<div class="muted">未选择任务批次</div>';
|
|
315
|
+
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
316
|
+
const chips = [
|
|
317
|
+
metaChip('Run ID', currentState.runId, { long: true }),
|
|
318
|
+
metaChip('仓库', basenamePath(currentState.repo), { title: currentState.repo }),
|
|
319
|
+
metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
|
|
320
|
+
metaChip('开始', formatDateTime(currentState.createdAt)),
|
|
321
|
+
metaChip('用时', `${durationSeconds(currentState.createdAt, runDurationEnd(currentState))} 秒`)
|
|
322
|
+
];
|
|
323
|
+
if (currentState.runner === 'tmux') {
|
|
324
|
+
if (hasRunTmuxMetadata(currentState)) {
|
|
325
|
+
chips.push(metaChip('终端', tmuxSessionName(currentState), {
|
|
326
|
+
long: true,
|
|
327
|
+
extra: `<button class="secondary copy-btn" onclick="copyTmuxRunCommand(event)">复制tmux attach指令</button>`
|
|
328
|
+
}));
|
|
329
|
+
} else {
|
|
330
|
+
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
chips.push(metaChip('刷新', `每 ${AUTO_REFRESH_MS / 1000} 秒`));
|
|
334
|
+
chips.push(metaChip('上次', lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发'));
|
|
335
|
+
return `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
|
|
336
|
+
}
|
|
256
337
|
async function loadTaskDescription() {
|
|
257
338
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
258
339
|
document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
|
|
@@ -290,7 +371,7 @@ function updateAutoRefreshHint() {
|
|
|
290
371
|
function taskStatusCell(t) {
|
|
291
372
|
if (t?.manualCompletion) {
|
|
292
373
|
const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
|
|
293
|
-
return
|
|
374
|
+
return `<span class="status-stack">${pill(original)}<span class="pill completed">手动标记成功已完成</span></span>`;
|
|
294
375
|
}
|
|
295
376
|
return pill(t?.status);
|
|
296
377
|
}
|
|
@@ -300,9 +381,13 @@ function taskActionCell(id, t) {
|
|
|
300
381
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
301
382
|
return `<button class="danger" onclick="markTaskCompleted(event, '${id}')">手动标记成功</button>`;
|
|
302
383
|
}
|
|
384
|
+
function shortSessionId(thread) {
|
|
385
|
+
const text = String(thread || '');
|
|
386
|
+
return text.length > 8 ? text.slice(-8) : text;
|
|
387
|
+
}
|
|
303
388
|
function sessionCell(thread) {
|
|
304
389
|
if (!thread) return '-';
|
|
305
|
-
return `<span class="session-cell"
|
|
390
|
+
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>`;
|
|
306
391
|
}
|
|
307
392
|
function taskStartedCell(t) {
|
|
308
393
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
@@ -312,29 +397,67 @@ function taskDurationCell(t) {
|
|
|
312
397
|
const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
|
|
313
398
|
return `${durationSeconds(t.startedAt, end)} 秒`;
|
|
314
399
|
}
|
|
400
|
+
function processExitCell(t) {
|
|
401
|
+
return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
|
|
402
|
+
}
|
|
403
|
+
function taskActionInfoCell(id, t) {
|
|
404
|
+
return `<div class="row-actions">${taskActionCell(id, t)}</div>`;
|
|
405
|
+
}
|
|
315
406
|
function taskRow(id, role, t) {
|
|
316
407
|
const thread = t?.codexThread?.id || '';
|
|
317
|
-
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>${
|
|
408
|
+
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>`;
|
|
318
409
|
}
|
|
319
410
|
function renderTasks() {
|
|
320
411
|
const s = currentState;
|
|
321
412
|
const rows = [taskRow('planner','planner',s.planner)];
|
|
413
|
+
const columnCount = 8;
|
|
322
414
|
if (Array.isArray(s.batches) && s.batches.length) {
|
|
323
415
|
for (const b of s.batches) {
|
|
324
416
|
const done = (b.tasks || []).filter(t => t.status === 'completed').length;
|
|
325
|
-
rows.push(`<tr class="batch-row"><td colspan="
|
|
417
|
+
rows.push(`<tr class="batch-row"><td colspan="${columnCount}">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
|
|
326
418
|
for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
|
|
327
419
|
}
|
|
328
420
|
} else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
|
|
329
421
|
rows.push(taskRow('judge','最终验收',s.judge));
|
|
330
|
-
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th
|
|
422
|
+
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>`;
|
|
423
|
+
}
|
|
424
|
+
const FILE_TAB_SETS = {
|
|
425
|
+
planner: [
|
|
426
|
+
['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
|
|
427
|
+
],
|
|
428
|
+
worker: [
|
|
429
|
+
['events.pretty', '执行过程'], ['last_message.md', '最终回复'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['result.json', '结果'], ['evidence.json', '证据'], ['events.jsonl', '事件日志']
|
|
430
|
+
],
|
|
431
|
+
judge: [
|
|
432
|
+
['verdict.json', '验收结论'], ['judge_input.json', '验收输入'], ['last_message.md', '最终回复'], ['events.pretty', '执行过程'], ['stderr.log', '错误日志'], ['prompt.md', '提示词'], ['events.jsonl', '事件日志']
|
|
433
|
+
]
|
|
434
|
+
};
|
|
435
|
+
function roleForSelectedTask() {
|
|
436
|
+
if (selectedTask === 'planner') return 'planner';
|
|
437
|
+
if (selectedTask === 'judge') return 'judge';
|
|
438
|
+
return selectedTask ? 'worker' : 'worker';
|
|
439
|
+
}
|
|
440
|
+
function fileTabsForSelectedTask() {
|
|
441
|
+
return FILE_TAB_SETS[roleForSelectedTask()] || FILE_TAB_SETS.worker;
|
|
442
|
+
}
|
|
443
|
+
function renderFileTabs() {
|
|
444
|
+
const el = document.getElementById('fileTabs');
|
|
445
|
+
if (!el) return;
|
|
446
|
+
if (!selectedTask) { el.innerHTML = ''; return; }
|
|
447
|
+
el.innerHTML = fileTabsForSelectedTask().map(([name, label]) => `<button class="secondary" onclick="loadFile('${name}')">${esc(label)}</button>`).join('');
|
|
331
448
|
}
|
|
332
449
|
async function selectTask(id) {
|
|
333
450
|
selectedTask = id;
|
|
334
451
|
document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
|
|
452
|
+
renderFileTabs();
|
|
335
453
|
renderTasks();
|
|
336
|
-
if (selectedFileName) await loadFile(selectedFileName);
|
|
337
|
-
else
|
|
454
|
+
if (selectedFileName && fileTabsForSelectedTask().some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
|
|
455
|
+
else {
|
|
456
|
+
selectedFileName = null;
|
|
457
|
+
document.getElementById('fileContent').textContent = '';
|
|
458
|
+
hideExecutionSummary();
|
|
459
|
+
updateCopyLastMessageButton();
|
|
460
|
+
}
|
|
338
461
|
}
|
|
339
462
|
async function loadFile(name, { preserveScroll = false } = {}) {
|
|
340
463
|
if (!selectedRun || !selectedTask) return;
|
|
@@ -352,6 +475,8 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
352
475
|
function clearFileView() {
|
|
353
476
|
document.getElementById('fileTitle').textContent = '点击任务后选择文件';
|
|
354
477
|
document.getElementById('fileContent').textContent = '';
|
|
478
|
+
const tabs = document.getElementById('fileTabs');
|
|
479
|
+
if (tabs) tabs.innerHTML = '';
|
|
355
480
|
hideExecutionSummary();
|
|
356
481
|
updateCopyLastMessageButton();
|
|
357
482
|
}
|
|
@@ -378,6 +503,18 @@ function hideExecutionSummary() {
|
|
|
378
503
|
el.classList.add('hidden');
|
|
379
504
|
el.innerHTML = '';
|
|
380
505
|
}
|
|
506
|
+
async function copyTmuxRunCommand(event) {
|
|
507
|
+
event.stopPropagation();
|
|
508
|
+
const command = runAttachCommand(currentState);
|
|
509
|
+
if (!command) return;
|
|
510
|
+
try {
|
|
511
|
+
await navigator.clipboard.writeText(command);
|
|
512
|
+
event.currentTarget.textContent = '已复制';
|
|
513
|
+
setTimeout(() => { event.currentTarget.textContent = '复制tmux attach指令'; }, 900);
|
|
514
|
+
} catch {
|
|
515
|
+
prompt('复制 tmux attach 命令', command);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
381
518
|
async function renderExecutionSummary() {
|
|
382
519
|
const el = document.getElementById('executionSummary');
|
|
383
520
|
let raw = '';
|
|
@@ -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
|
+
}
|