input-kanban 0.0.6 → 0.0.8
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/PROJECT_GUIDE.md +63 -3
- package/README.en.md +41 -1
- package/README.md +41 -1
- package/RELEASE_NOTES.md +86 -0
- package/bin/input-kanban-timestamp-events.js +34 -0
- package/bin/input-kanban.js +532 -22
- package/package.json +3 -3
- package/public/assets/input-kanban-mask-icon.svg +3 -0
- package/public/index.html +301 -59
- package/src/orchestrator.js +186 -20
- package/src/runners/headlessRunner.js +32 -1
- package/src/runners/tmuxRunner.js +6 -3
- package/src/server.js +5 -1
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; }
|
|
@@ -81,15 +84,30 @@
|
|
|
81
84
|
.meta-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
85
|
.meta-chip.long .meta-value { max-width: min(680px, 72vw); }
|
|
83
86
|
.meta-chip .copy-btn { margin-left: 2px; }
|
|
87
|
+
.refresh-pulse-chip { width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--line); border-radius: 999px; background: #020617; }
|
|
88
|
+
.refresh-pulse-dot { width: 12px; height: 12px; border: 2px solid #60a5fa; border-top-color: transparent; border-radius: 999px; opacity: .62; }
|
|
89
|
+
.refresh-pulse-chip.pulse .refresh-pulse-dot { animation: refresh-spin .8s ease-out; }
|
|
90
|
+
@keyframes refresh-spin { 0% { transform: rotate(0deg) scale(.75); opacity: 1; } 70% { transform: rotate(300deg) scale(1.18); opacity: 1; } 100% { transform: rotate(360deg) scale(1); opacity: .62; } }
|
|
84
91
|
.log-panel { margin-top: 16px; }
|
|
85
92
|
.file-tabs button { font-size: 13px; }
|
|
86
93
|
.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; }
|
|
94
|
+
.rename-btn { opacity: 0; pointer-events: none; transition: opacity .15s ease; }
|
|
95
|
+
.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; }
|
|
96
|
+
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
97
|
+
.archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
|
|
98
|
+
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
87
99
|
.session-cell { word-break: break-all; }
|
|
88
100
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
89
101
|
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
90
102
|
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
91
|
-
.execution-summary { display: flex; flex-wrap: wrap; gap:
|
|
103
|
+
.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
104
|
.execution-summary.hidden { display: none; }
|
|
105
|
+
.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; }
|
|
106
|
+
.summary-chip.command-type { background: rgba(37,99,235,.12); border-color: rgba(96,165,250,.35); }
|
|
107
|
+
.summary-break { flex-basis: 100%; height: 0; }
|
|
108
|
+
.summary-label { color: var(--muted); font-weight: 800; }
|
|
109
|
+
.summary-value { color: #e2e8f0; font-weight: 800; }
|
|
110
|
+
.summary-duration { color: #bfdbfe; font-weight: 800; }
|
|
93
111
|
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
94
112
|
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
95
113
|
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
@@ -97,6 +115,10 @@
|
|
|
97
115
|
.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
116
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
99
117
|
.floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
|
|
118
|
+
.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); }
|
|
119
|
+
.modal-backdrop.hidden { display: none; }
|
|
120
|
+
.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; }
|
|
121
|
+
.modal-card textarea { min-height: 220px; }
|
|
100
122
|
</style>
|
|
101
123
|
</head>
|
|
102
124
|
<body>
|
|
@@ -153,21 +175,36 @@
|
|
|
153
175
|
</section>
|
|
154
176
|
|
|
155
177
|
<section id="filePanel" class="log-panel">
|
|
156
|
-
<h2
|
|
157
|
-
<div id="fileTitle" class="muted"
|
|
178
|
+
<h2>任务详情</h2>
|
|
179
|
+
<div id="fileTitle" class="muted">点击任务后查看详情</div>
|
|
158
180
|
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
159
181
|
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
160
182
|
<div class="file-content-wrap">
|
|
161
|
-
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="
|
|
183
|
+
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制当前内容" onclick="copyFileContent(event)">⧉</button>
|
|
162
184
|
<pre id="fileContent"></pre>
|
|
163
185
|
</div>
|
|
164
186
|
</section>
|
|
165
187
|
</div>
|
|
166
188
|
</main>
|
|
189
|
+
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
190
|
+
<div class="modal-card">
|
|
191
|
+
<h2>手动标记成功</h2>
|
|
192
|
+
<div id="manualCompleteTitle" class="muted"></div>
|
|
193
|
+
<label>人工成功执行结果</label>
|
|
194
|
+
<textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
|
|
195
|
+
<div class="muted">该内容会保存为 manual_result.md,显示在“结果”中,并作为 final judge 的人工成功证据。</div>
|
|
196
|
+
<div class="toolbar">
|
|
197
|
+
<button onclick="submitManualComplete()">确认标记成功</button>
|
|
198
|
+
<button class="secondary" onclick="closeManualCompleteModal()">取消</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
167
202
|
<script>
|
|
168
203
|
let selectedRun = null;
|
|
169
204
|
let selectedTask = null;
|
|
170
205
|
let selectedFileName = null;
|
|
206
|
+
let manualCompleteTaskId = null;
|
|
207
|
+
let pendingArchiveRunId = null;
|
|
171
208
|
let currentState = null;
|
|
172
209
|
let lastAutoRefreshAt = null;
|
|
173
210
|
let runListVisibleCount = 10;
|
|
@@ -178,10 +215,27 @@ const RUN_LIST_PAGE_SIZE = 10;
|
|
|
178
215
|
|
|
179
216
|
async function api(path, opts={}) {
|
|
180
217
|
const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
|
181
|
-
if (!res.ok)
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const error = new Error(await res.text());
|
|
220
|
+
error.status = res.status;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
182
223
|
const ct = res.headers.get('content-type') || '';
|
|
183
224
|
return ct.includes('application/json') ? res.json() : res.text();
|
|
184
225
|
}
|
|
226
|
+
function errorDetail(error) {
|
|
227
|
+
const message = error?.message || String(error || '');
|
|
228
|
+
try { return JSON.parse(message).error || message; }
|
|
229
|
+
catch { return message; }
|
|
230
|
+
}
|
|
231
|
+
function userFacingErrorMessage(error) {
|
|
232
|
+
const detail = errorDetail(error);
|
|
233
|
+
if (/planner already running/i.test(detail)) return '任务拆分正在进行中,请稍后查看结果。';
|
|
234
|
+
if (/judge already running/i.test(detail)) return '验收正在进行中,请稍后查看结果。';
|
|
235
|
+
if (/already running/i.test(detail)) return '任务正在进行中,请稍后查看结果。';
|
|
236
|
+
if (/cannot archive.*running/i.test(detail)) return '任务仍在执行中,请先停止后再归档。';
|
|
237
|
+
return error?.message || String(error);
|
|
238
|
+
}
|
|
185
239
|
const statusText = {
|
|
186
240
|
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
187
241
|
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
@@ -222,7 +276,7 @@ function runDurationEnd(s) {
|
|
|
222
276
|
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
223
277
|
]) || s.updatedAt;
|
|
224
278
|
}
|
|
225
|
-
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, runDurationEnd(s))
|
|
279
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
|
|
226
280
|
function basenamePath(value) {
|
|
227
281
|
const parts = String(value || '').split(/[\\/]/).filter(Boolean);
|
|
228
282
|
return parts.at(-1) || value || '-';
|
|
@@ -230,6 +284,12 @@ function basenamePath(value) {
|
|
|
230
284
|
function metaChip(label, value, { title = value, danger = false, long = false, extra = '' } = {}) {
|
|
231
285
|
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
286
|
}
|
|
287
|
+
function editIcon() {
|
|
288
|
+
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>';
|
|
289
|
+
}
|
|
290
|
+
function archiveIcon() {
|
|
291
|
+
return '<svg class="icon-svg" viewBox="0 0 24 24" focusable="false" aria-hidden="true"><path d="M4 5h16v4H4V5Z" fill="currentColor"/><path d="M6 10h12v9H6v-9Z" fill="currentColor" opacity=".72"/><path d="M9 13h6" stroke="#020617" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
292
|
+
}
|
|
233
293
|
function isTmuxMode() { return currentState?.runner === 'tmux'; }
|
|
234
294
|
function taskById(id) {
|
|
235
295
|
if (!currentState) return null;
|
|
@@ -250,6 +310,14 @@ function runAttachCommand(state = currentState) {
|
|
|
250
310
|
if (!hasRunTmuxMetadata(state)) return '';
|
|
251
311
|
return state?.tmux?.tmuxAttachCommand || `tmux attach-session -t ${tmuxSessionName(state)}`;
|
|
252
312
|
}
|
|
313
|
+
function runCardDurationEnd(run) {
|
|
314
|
+
const cached = statusByRunId.get(run.runId);
|
|
315
|
+
if (cached) return runDurationEnd(cached);
|
|
316
|
+
return run.durationEnd || null;
|
|
317
|
+
}
|
|
318
|
+
function runCardDurationText(run) {
|
|
319
|
+
return formatDurationMs(durationSeconds(run.createdAt, runCardDurationEnd(run)) * 1000);
|
|
320
|
+
}
|
|
253
321
|
function runListHasTmuxMetadata(run) {
|
|
254
322
|
if (run?.runner !== 'tmux') return false;
|
|
255
323
|
if (selectedRun === run.runId && currentState?.runId === run.runId) return hasRunTmuxMetadata(currentState);
|
|
@@ -282,6 +350,8 @@ async function createRun() {
|
|
|
282
350
|
clearFileView();
|
|
283
351
|
hideCreateForm();
|
|
284
352
|
await refreshRuns(); await refreshSelected();
|
|
353
|
+
await api(`/api/runs/${selectedRun}/plan`, { method: 'POST' });
|
|
354
|
+
await refreshSelected();
|
|
285
355
|
}
|
|
286
356
|
async function refreshRuns() {
|
|
287
357
|
const data = await api('/api/runs');
|
|
@@ -291,11 +361,12 @@ async function refreshRuns() {
|
|
|
291
361
|
function renderRunList() {
|
|
292
362
|
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
293
363
|
const cards = visibleRuns.map(r => `
|
|
294
|
-
<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>
|
|
364
|
+
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')" onmouseleave="clearArchiveConfirm('${r.runId}')">
|
|
365
|
+
<div class="run-card-title"><span class="run-card-name-wrap"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span><span class="run-card-title-actions"><button class="secondary copy-btn rename-btn" title="修改任务批次名称" onclick="renameRunLabel(event, '${r.runId}')">${editIcon()}</button><button class="secondary copy-btn rename-btn ${pendingArchiveRunId === r.runId ? 'archive-confirm-btn' : ''}" title="${pendingArchiveRunId === r.runId ? '再次点击确认归档' : '归档任务批次(运行中请先停止)'}" onclick="archiveRunFromCard(event, '${r.runId}')">${pendingArchiveRunId === r.runId ? '确认' : archiveIcon()}</button></span></span><span>${pill(r.status)}</span></div>
|
|
296
366
|
<div class="run-card-meta">
|
|
297
|
-
${metaChip('
|
|
367
|
+
${metaChip('仓库', basenamePath(r.repo), { title: r.repo, long: true, extra: `<button class="secondary copy-btn" title="复制仓库地址" onclick="copyRunRepoPath(event, '${r.runId}')">⧉</button>` })}
|
|
298
368
|
${metaChip('创建', formatDateTime(r.createdAt))}
|
|
369
|
+
${metaChip('用时', runCardDurationText(r))}
|
|
299
370
|
${metaChip('进度', `${r.completed}/${r.total}`)}
|
|
300
371
|
${metaChip('执行中', r.running)}
|
|
301
372
|
${metaChip('失败', r.failed, { danger: Number(r.failed) > 0 })}
|
|
@@ -317,6 +388,7 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
317
388
|
statusByRunId.set(selectedRun, currentState);
|
|
318
389
|
if (auto) lastAutoRefreshAt = new Date();
|
|
319
390
|
document.getElementById('selected').innerHTML = renderSelectedHeader();
|
|
391
|
+
if (auto) requestAnimationFrame(triggerRefreshPulse);
|
|
320
392
|
updateAutoRefreshHint();
|
|
321
393
|
updateRunNotice();
|
|
322
394
|
await loadTaskDescription();
|
|
@@ -335,7 +407,7 @@ function renderSelectedHeader() {
|
|
|
335
407
|
}),
|
|
336
408
|
metaChip('沙箱', sandbox, { danger: sandbox === 'danger-full-access' }),
|
|
337
409
|
metaChip('开始', formatDateTime(currentState.createdAt)),
|
|
338
|
-
metaChip('用时',
|
|
410
|
+
metaChip('用时', formatDurationMs(durationSeconds(currentState.createdAt, runDurationEnd(currentState)) * 1000))
|
|
339
411
|
];
|
|
340
412
|
if (currentState.runner === 'tmux') {
|
|
341
413
|
if (hasRunTmuxMetadata(currentState)) {
|
|
@@ -347,14 +419,24 @@ function renderSelectedHeader() {
|
|
|
347
419
|
chips.push(metaChip('终端', 'tmux 现场尚未生成'));
|
|
348
420
|
}
|
|
349
421
|
}
|
|
350
|
-
chips.push(
|
|
351
|
-
|
|
352
|
-
return `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${chips.join('')}</div>`;
|
|
422
|
+
chips.push(refreshPulseChip());
|
|
423
|
+
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>`;
|
|
353
424
|
}
|
|
354
425
|
async function loadTaskDescription() {
|
|
355
426
|
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
356
427
|
document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
|
|
357
428
|
}
|
|
429
|
+
function refreshPulseChip() {
|
|
430
|
+
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
431
|
+
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
432
|
+
}
|
|
433
|
+
function triggerRefreshPulse() {
|
|
434
|
+
const el = document.getElementById('refreshPulse');
|
|
435
|
+
if (!el) return;
|
|
436
|
+
el.classList.remove('pulse');
|
|
437
|
+
void el.offsetWidth;
|
|
438
|
+
el.classList.add('pulse');
|
|
439
|
+
}
|
|
358
440
|
function updateRunNotice() {
|
|
359
441
|
const el = document.getElementById('runNotice');
|
|
360
442
|
if (!el) return;
|
|
@@ -409,10 +491,17 @@ function sessionCell(thread) {
|
|
|
409
491
|
function taskStartedCell(t) {
|
|
410
492
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
411
493
|
}
|
|
412
|
-
function
|
|
413
|
-
if (!t?.startedAt) return
|
|
494
|
+
function taskDurationMs(t) {
|
|
495
|
+
if (!t?.startedAt) return null;
|
|
414
496
|
const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
|
|
415
|
-
|
|
497
|
+
const endMs = end ? new Date(end).getTime() : Date.now();
|
|
498
|
+
const startMs = new Date(t.startedAt).getTime();
|
|
499
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
500
|
+
return Math.max(0, endMs - startMs);
|
|
501
|
+
}
|
|
502
|
+
function taskDurationCell(t) {
|
|
503
|
+
const ms = taskDurationMs(t);
|
|
504
|
+
return ms === null ? '-' : formatDurationMs(ms);
|
|
416
505
|
}
|
|
417
506
|
function processExitCell(t) {
|
|
418
507
|
return `${esc(t?.pid || '-')} / ${esc(t?.exitCode ?? '-')}`;
|
|
@@ -468,12 +557,17 @@ async function selectTask(id) {
|
|
|
468
557
|
document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
|
|
469
558
|
renderFileTabs();
|
|
470
559
|
renderTasks();
|
|
471
|
-
|
|
560
|
+
const tabs = fileTabsForSelectedTask();
|
|
561
|
+
if (selectedFileName && tabs.some(([name]) => name === selectedFileName)) await loadFile(selectedFileName);
|
|
472
562
|
else {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
563
|
+
const defaultTab = tabs.find(([name]) => name === 'events.pretty') || tabs[0];
|
|
564
|
+
if (defaultTab) await loadFile(defaultTab[0]);
|
|
565
|
+
else {
|
|
566
|
+
selectedFileName = null;
|
|
567
|
+
document.getElementById('fileContent').textContent = '';
|
|
568
|
+
hideExecutionSummary();
|
|
569
|
+
updateCopyLastMessageButton();
|
|
570
|
+
}
|
|
477
571
|
}
|
|
478
572
|
}
|
|
479
573
|
async function loadFile(name, { preserveScroll = false } = {}) {
|
|
@@ -481,7 +575,14 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
481
575
|
selectedFileName = name;
|
|
482
576
|
const pre = document.getElementById('fileContent');
|
|
483
577
|
const previousScrollTop = pre.scrollTop;
|
|
484
|
-
|
|
578
|
+
let text;
|
|
579
|
+
const selected = taskById(selectedTask);
|
|
580
|
+
if (name === 'result.json' && selected?.manualCompletion?.hasManualResult) {
|
|
581
|
+
const manualText = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=manual_result.md`);
|
|
582
|
+
text = manualText ? `【这次是人工结果】\n\n${manualText}` : await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
583
|
+
} else {
|
|
584
|
+
text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
585
|
+
}
|
|
485
586
|
pre.textContent = text;
|
|
486
587
|
if (preserveScroll) pre.scrollTop = previousScrollTop;
|
|
487
588
|
else pre.scrollTop = 0;
|
|
@@ -490,7 +591,7 @@ async function loadFile(name, { preserveScroll = false } = {}) {
|
|
|
490
591
|
updateCopyLastMessageButton();
|
|
491
592
|
}
|
|
492
593
|
function clearFileView() {
|
|
493
|
-
document.getElementById('fileTitle').textContent = '
|
|
594
|
+
document.getElementById('fileTitle').textContent = '点击任务后查看详情';
|
|
494
595
|
document.getElementById('fileContent').textContent = '';
|
|
495
596
|
const tabs = document.getElementById('fileTabs');
|
|
496
597
|
if (tabs) tabs.innerHTML = '';
|
|
@@ -500,7 +601,9 @@ function clearFileView() {
|
|
|
500
601
|
function updateCopyLastMessageButton() {
|
|
501
602
|
const button = document.getElementById('copyLastMessageBtn');
|
|
502
603
|
if (!button) return;
|
|
503
|
-
|
|
604
|
+
const copyableFiles = new Set(['last_message.md', 'verdict.json']);
|
|
605
|
+
button.classList.toggle('hidden', !copyableFiles.has(selectedFileName));
|
|
606
|
+
button.title = selectedFileName === 'verdict.json' ? '复制验收结论内容' : '复制最终回复内容';
|
|
504
607
|
button.textContent = '⧉';
|
|
505
608
|
}
|
|
506
609
|
async function copyFileContent(event) {
|
|
@@ -520,9 +623,8 @@ function hideExecutionSummary() {
|
|
|
520
623
|
el.classList.add('hidden');
|
|
521
624
|
el.innerHTML = '';
|
|
522
625
|
}
|
|
523
|
-
async function copyRepoPath(event) {
|
|
626
|
+
async function copyRepoPath(event, repoPath = currentState?.repo || '') {
|
|
524
627
|
event.stopPropagation();
|
|
525
|
-
const repoPath = currentState?.repo || '';
|
|
526
628
|
if (!repoPath) return;
|
|
527
629
|
try {
|
|
528
630
|
await navigator.clipboard.writeText(repoPath);
|
|
@@ -532,6 +634,10 @@ async function copyRepoPath(event) {
|
|
|
532
634
|
prompt('复制仓库地址', repoPath);
|
|
533
635
|
}
|
|
534
636
|
}
|
|
637
|
+
async function copyRunRepoPath(event, runId) {
|
|
638
|
+
const repoPath = latestRuns.find(run => run.runId === runId)?.repo || '';
|
|
639
|
+
await copyRepoPath(event, repoPath);
|
|
640
|
+
}
|
|
535
641
|
async function copyTmuxRunCommand(event) {
|
|
536
642
|
event.stopPropagation();
|
|
537
643
|
const command = runAttachCommand(currentState);
|
|
@@ -547,32 +653,109 @@ async function copyTmuxRunCommand(event) {
|
|
|
547
653
|
async function renderExecutionSummary() {
|
|
548
654
|
const el = document.getElementById('executionSummary');
|
|
549
655
|
let raw = '';
|
|
656
|
+
let timedRaw = '';
|
|
550
657
|
try { raw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events.jsonl`); }
|
|
551
658
|
catch { raw = ''; }
|
|
552
|
-
|
|
659
|
+
try { timedRaw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events_timed.jsonl`); }
|
|
660
|
+
catch { timedRaw = ''; }
|
|
661
|
+
const task = taskById(selectedTask);
|
|
662
|
+
const taskMs = taskDurationMs(task);
|
|
663
|
+
const summary = summarizeEventsJsonl(timedRaw || raw, { taskMs });
|
|
664
|
+
const taskDuration = taskDurationCell(task);
|
|
665
|
+
const statChip = (label, value) => `<span class="summary-chip"><span class="summary-label">${esc(label)}</span><span class="summary-value">${esc(value)}</span></span>`;
|
|
666
|
+
const commandTypeChips = summary.commandTypes.length
|
|
667
|
+
? 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('')
|
|
668
|
+
: '<span class="summary-chip command-type"><span class="summary-value">-</span></span>';
|
|
553
669
|
el.classList.remove('hidden');
|
|
554
|
-
el.innerHTML =
|
|
555
|
-
}
|
|
556
|
-
function
|
|
557
|
-
const
|
|
670
|
+
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>`;
|
|
671
|
+
}
|
|
672
|
+
function eventTimeMs(event) {
|
|
673
|
+
const value = event.receivedAt ?? event.timestamp ?? event.time ?? event.created_at ?? event.createdAt ?? event.ts;
|
|
674
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value > 1e12 ? value : value * 1000;
|
|
675
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
676
|
+
const ms = Date.parse(value);
|
|
677
|
+
return Number.isFinite(ms) ? ms : null;
|
|
678
|
+
}
|
|
679
|
+
function formatDurationMs(ms) {
|
|
680
|
+
if (!Number.isFinite(ms) || ms < 0) return '-';
|
|
681
|
+
if (ms < 1000) return `${Math.round(ms)}毫秒`;
|
|
682
|
+
const seconds = ms / 1000;
|
|
683
|
+
if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}秒`;
|
|
684
|
+
const minutes = Math.floor(seconds / 60);
|
|
685
|
+
const rest = Math.round(seconds % 60);
|
|
686
|
+
return `${minutes}分${String(rest).padStart(2, '0')}秒`;
|
|
687
|
+
}
|
|
688
|
+
function summarizeEventsJsonl(raw, { taskMs = null } = {}) {
|
|
689
|
+
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: '-' };
|
|
690
|
+
const commandStarts = new Map();
|
|
691
|
+
const commandCountsByKind = new Map();
|
|
692
|
+
const commandDurationsByKind = new Map();
|
|
693
|
+
let firstEventMs = null;
|
|
694
|
+
let lastEventMs = null;
|
|
695
|
+
let commandDurationTotalMs = 0;
|
|
558
696
|
for (const line of String(raw || '').split(/\r?\n/).filter(Boolean)) {
|
|
559
697
|
let event;
|
|
560
698
|
try { event = JSON.parse(line); } catch { continue; }
|
|
699
|
+
const envelopeTimeMs = eventTimeMs(event);
|
|
700
|
+
event = event.event || event;
|
|
561
701
|
summary.events++;
|
|
702
|
+
const timeMs = envelopeTimeMs ?? eventTimeMs(event);
|
|
703
|
+
if (timeMs !== null) {
|
|
704
|
+
firstEventMs = firstEventMs === null ? timeMs : Math.min(firstEventMs, timeMs);
|
|
705
|
+
lastEventMs = lastEventMs === null ? timeMs : Math.max(lastEventMs, timeMs);
|
|
706
|
+
}
|
|
562
707
|
const item = event.item || {};
|
|
563
708
|
const type = item.type || '';
|
|
564
709
|
if (type === 'command_execution') {
|
|
565
|
-
|
|
566
|
-
if (event.type === 'item.completed') summary.commandCompleted++;
|
|
710
|
+
const commandId = item.id || `command-${summary.commandStarted + summary.commandCompleted}`;
|
|
567
711
|
const kind = commandKind(item.command || 'unknown');
|
|
712
|
+
if (event.type === 'item.started') {
|
|
713
|
+
summary.commandStarted++;
|
|
714
|
+
if (timeMs !== null) commandStarts.set(commandId, { timeMs, kind });
|
|
715
|
+
}
|
|
716
|
+
if (event.type === 'item.completed') {
|
|
717
|
+
summary.commandCompleted++;
|
|
718
|
+
const started = commandStarts.get(commandId);
|
|
719
|
+
const completedKind = started?.kind || kind;
|
|
720
|
+
commandCountsByKind.set(completedKind, (commandCountsByKind.get(completedKind) || 0) + 1);
|
|
721
|
+
const inlineDuration = Number(item.duration_ms ?? item.durationMs ?? event.duration_ms ?? event.durationMs);
|
|
722
|
+
let durationMs = null;
|
|
723
|
+
if (Number.isFinite(inlineDuration) && inlineDuration >= 0) durationMs = inlineDuration;
|
|
724
|
+
else if (timeMs !== null && started && Number.isFinite(started.timeMs)) durationMs = Math.max(0, timeMs - started.timeMs);
|
|
725
|
+
if (durationMs !== null) {
|
|
726
|
+
commandDurationTotalMs += durationMs;
|
|
727
|
+
const current = commandDurationsByKind.get(completedKind) || { ms: 0, count: 0 };
|
|
728
|
+
commandDurationsByKind.set(completedKind, { ms: current.ms + durationMs, count: current.count + 1 });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
568
731
|
summary.commandKinds.set(kind, (summary.commandKinds.get(kind) || 0) + 1);
|
|
569
732
|
}
|
|
570
|
-
if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
|
|
571
|
-
if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
|
|
572
|
-
if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
|
|
573
|
-
if (type === 'reasoning') summary.reasoning++;
|
|
733
|
+
else if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
|
|
734
|
+
else if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
|
|
735
|
+
else if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
|
|
736
|
+
else if (type === 'reasoning') summary.reasoning++;
|
|
737
|
+
else summary.systemEvents++;
|
|
738
|
+
}
|
|
739
|
+
let eventDurationMs = null;
|
|
740
|
+
if (firstEventMs !== null && lastEventMs !== null) {
|
|
741
|
+
eventDurationMs = Math.max(0, lastEventMs - firstEventMs);
|
|
742
|
+
summary.eventDurationText = formatDurationMs(eventDurationMs);
|
|
574
743
|
}
|
|
575
|
-
|
|
744
|
+
if (commandDurationTotalMs > 0) summary.commandDurationText = formatDurationMs(commandDurationTotalMs);
|
|
745
|
+
if (eventDurationMs !== null) summary.modelOrchestrationText = formatDurationMs(Math.max(0, eventDurationMs - commandDurationTotalMs));
|
|
746
|
+
if (eventDurationMs !== null && Number.isFinite(taskMs) && taskMs >= 0) summary.startFinishText = formatDurationMs(Math.max(0, taskMs - eventDurationMs));
|
|
747
|
+
const commandTypeNames = new Set([...commandCountsByKind.keys(), ...commandDurationsByKind.keys()]);
|
|
748
|
+
summary.commandTypes = [...commandTypeNames].map(kind => {
|
|
749
|
+
const duration = commandDurationsByKind.get(kind);
|
|
750
|
+
return {
|
|
751
|
+
kind,
|
|
752
|
+
count: commandCountsByKind.get(kind) || duration?.count || 0,
|
|
753
|
+
durationMs: duration?.ms ?? null,
|
|
754
|
+
durationText: duration ? formatDurationMs(duration.ms) : '-',
|
|
755
|
+
percentText: duration && Number.isFinite(taskMs) && taskMs > 0 ? `${Math.round((duration.ms / taskMs) * 100)}%` : '-'
|
|
756
|
+
};
|
|
757
|
+
}).sort((a, b) => b.count - a.count || (b.durationMs || 0) - (a.durationMs || 0)).slice(0, 6);
|
|
758
|
+
const kinds = summary.commandTypes.map(item => [item.kind, item.count]);
|
|
576
759
|
summary.commandKindsText = kinds.length ? kinds.map(([kind, count]) => `${kind}:${count}`).join(' / ') : '-';
|
|
577
760
|
return summary;
|
|
578
761
|
}
|
|
@@ -602,12 +785,32 @@ async function copySessionId(event, thread) {
|
|
|
602
785
|
prompt('复制 Codex 会话ID', thread);
|
|
603
786
|
}
|
|
604
787
|
}
|
|
788
|
+
function labelForRun(runId) {
|
|
789
|
+
if (currentState?.runId === runId) return currentState.label || '';
|
|
790
|
+
return (latestRuns.find(run => run.runId === runId)?.label) || '';
|
|
791
|
+
}
|
|
792
|
+
async function renameRunLabel(event, runId = selectedRun) {
|
|
793
|
+
event.stopPropagation();
|
|
794
|
+
if (!runId) return;
|
|
795
|
+
const nextLabel = prompt('修改任务批次名称', labelForRun(runId));
|
|
796
|
+
if (nextLabel === null) return;
|
|
797
|
+
const label = nextLabel.trim();
|
|
798
|
+
if (!label) { alert('任务批次名称不能为空'); return; }
|
|
799
|
+
await runAction(async () => {
|
|
800
|
+
await api(`/api/runs/${runId}/label`, { method: 'PATCH', body: JSON.stringify({ label }) });
|
|
801
|
+
if (selectedRun === runId) await refreshSelected();
|
|
802
|
+
else await refreshRuns();
|
|
803
|
+
});
|
|
804
|
+
}
|
|
605
805
|
async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
606
806
|
async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
|
|
607
807
|
async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
608
808
|
async function runAction(fn) {
|
|
609
809
|
try { await fn(); }
|
|
610
|
-
catch (error) {
|
|
810
|
+
catch (error) {
|
|
811
|
+
console.error('操作失败', error);
|
|
812
|
+
alert(userFacingErrorMessage(error));
|
|
813
|
+
}
|
|
611
814
|
}
|
|
612
815
|
async function stopSelectedRun() {
|
|
613
816
|
if (!selectedRun) return;
|
|
@@ -619,36 +822,75 @@ async function stopSelectedRun() {
|
|
|
619
822
|
});
|
|
620
823
|
await refreshSelected();
|
|
621
824
|
}
|
|
825
|
+
function clearArchiveConfirm(runId) {
|
|
826
|
+
if (pendingArchiveRunId !== runId) return;
|
|
827
|
+
pendingArchiveRunId = null;
|
|
828
|
+
renderRunList();
|
|
829
|
+
}
|
|
830
|
+
async function archiveRunFromCard(event, runId) {
|
|
831
|
+
event.stopPropagation();
|
|
832
|
+
if (pendingArchiveRunId !== runId) {
|
|
833
|
+
pendingArchiveRunId = runId;
|
|
834
|
+
renderRunList();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
pendingArchiveRunId = null;
|
|
838
|
+
await archiveRunById(runId, { confirmFirst: false });
|
|
839
|
+
}
|
|
840
|
+
async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
841
|
+
if (!runId) return;
|
|
842
|
+
if (confirmFirst) {
|
|
843
|
+
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
844
|
+
if (!ok) return;
|
|
845
|
+
}
|
|
846
|
+
await runAction(async () => {
|
|
847
|
+
await api(`/api/runs/${runId}/archive`, {
|
|
848
|
+
method: 'POST',
|
|
849
|
+
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
850
|
+
});
|
|
851
|
+
if (selectedRun === runId) {
|
|
852
|
+
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
853
|
+
document.getElementById('selected').textContent = '未选择任务批次';
|
|
854
|
+
document.getElementById('taskDescription').textContent = '未选择任务批次';
|
|
855
|
+
document.getElementById('tasks').innerHTML = '';
|
|
856
|
+
clearFileView();
|
|
857
|
+
updateAutoRefreshHint();
|
|
858
|
+
}
|
|
859
|
+
await refreshRuns();
|
|
860
|
+
});
|
|
861
|
+
}
|
|
622
862
|
async function archiveSelectedRun() {
|
|
623
863
|
if (!selectedRun) return;
|
|
624
|
-
|
|
625
|
-
if (!ok) return;
|
|
626
|
-
await api(`/api/runs/${selectedRun}/archive`, {
|
|
627
|
-
method: 'POST',
|
|
628
|
-
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
629
|
-
});
|
|
630
|
-
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
631
|
-
document.getElementById('selected').textContent = '未选择任务批次';
|
|
632
|
-
document.getElementById('taskDescription').textContent = '未选择任务批次';
|
|
633
|
-
document.getElementById('tasks').innerHTML = '';
|
|
634
|
-
clearFileView();
|
|
635
|
-
updateAutoRefreshHint();
|
|
636
|
-
await refreshRuns();
|
|
864
|
+
await archiveRunById(selectedRun);
|
|
637
865
|
}
|
|
638
866
|
async function markTaskCompleted(event, taskId) {
|
|
639
867
|
event.stopPropagation();
|
|
640
868
|
if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
|
|
641
869
|
const task = (currentState?.tasks || []).find(t => t.id === taskId);
|
|
642
870
|
if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
871
|
+
manualCompleteTaskId = taskId;
|
|
872
|
+
document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
|
|
873
|
+
document.getElementById('manualCompleteResult').value = '';
|
|
874
|
+
document.getElementById('manualCompleteModal').classList.remove('hidden');
|
|
875
|
+
setTimeout(() => document.getElementById('manualCompleteResult').focus(), 0);
|
|
876
|
+
}
|
|
877
|
+
function closeManualCompleteModal() {
|
|
878
|
+
manualCompleteTaskId = null;
|
|
879
|
+
document.getElementById('manualCompleteModal').classList.add('hidden');
|
|
880
|
+
}
|
|
881
|
+
async function submitManualComplete() {
|
|
882
|
+
const taskId = manualCompleteTaskId;
|
|
883
|
+
if (!selectedRun || !taskId) return;
|
|
884
|
+
const resultText = document.getElementById('manualCompleteResult').value.trim();
|
|
885
|
+
if (!resultText) { alert('请粘贴人工成功执行结果。'); return; }
|
|
646
886
|
await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
|
|
647
887
|
method: 'POST',
|
|
648
|
-
body: JSON.stringify({ reason: 'manual success confirmed from dashboard' })
|
|
888
|
+
body: JSON.stringify({ reason: 'manual success confirmed from dashboard', resultText })
|
|
649
889
|
});
|
|
890
|
+
closeManualCompleteModal();
|
|
891
|
+
selectedTask = taskId;
|
|
650
892
|
await refreshSelected();
|
|
651
|
-
await loadFile('
|
|
893
|
+
await loadFile('result.json');
|
|
652
894
|
}
|
|
653
895
|
|
|
654
896
|
loadHealth().then(refreshRuns);
|