input-kanban 0.0.14 → 0.0.16
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/RELEASE_NOTES.md +34 -0
- package/package.json +1 -1
- package/public/index.html +305 -40
- package/src/orchestrator.js +34 -3
- package/src/server.js +167 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Release Notes
|
|
2
2
|
|
|
3
|
+
## v0.0.16
|
|
4
|
+
|
|
5
|
+
### Highlights
|
|
6
|
+
|
|
7
|
+
- Add a low-profile footer entry for session management, covering both board-managed and local Codex sessions.
|
|
8
|
+
- Show board/local tabs in the session modal and classify sessions with lightweight board-managed metadata.
|
|
9
|
+
- Add local Codex process visibility in the session modal, including resumable sessions, other Codex-related processes, and total memory usage in the modal header.
|
|
10
|
+
- Simplify the session modal layout with a compact close control, adjustable height, wider default width, and reduced empty spacing.
|
|
11
|
+
- Keep only meaningful session status badges, hide `unknown` in session cards, and move session IDs/copy actions to the right side.
|
|
12
|
+
|
|
13
|
+
### Verification
|
|
14
|
+
|
|
15
|
+
- `node --test test/server-static.test.js` passed locally.
|
|
16
|
+
- `node --test test/frontend-tmux-ui.test.js` passed locally.
|
|
17
|
+
- Windows-native validation passed on `zhangxing_win` with `npm run check` in the Windows release-candidate working tree.
|
|
18
|
+
|
|
19
|
+
## v0.0.15
|
|
20
|
+
|
|
21
|
+
### Highlights
|
|
22
|
+
|
|
23
|
+
- Absorb PR #3 recovery hardening without directly merging its older `v0.0.13` branch, preserving the `v0.0.14` Plan Approval Gate and Web layout changes.
|
|
24
|
+
- Route blocked-run dashboard execution actions to `/api/runs/:id/retry`, so `batch_blocked` runs retry failed/unknown tasks instead of hitting the dispatch endpoint.
|
|
25
|
+
- Remove stale Web `workers_completed` / `workers_failed` UI state handling now that backend run status uses `batches_completed` and `batch_blocked`.
|
|
26
|
+
- Harden final judge starts: reject archived/stopped runs, duplicate running judges, and completed judges; failed judges are archived to `judge_attempts/` before retrying.
|
|
27
|
+
- Add short `/api/codex` detection caching to avoid repeatedly spawning Codex detection during frequent dashboard refreshes.
|
|
28
|
+
- Keep task-table Codex session IDs and their copy buttons on one line by widening the session column and using a compact inline layout.
|
|
29
|
+
|
|
30
|
+
### Verification
|
|
31
|
+
|
|
32
|
+
- `npm run check` passed locally with 84 tests.
|
|
33
|
+
- `npm pack --dry-run` passed for `input-kanban@0.0.15`.
|
|
34
|
+
- Windows release-candidate validation on `zhangxing_win` passed with 84 tests.
|
|
35
|
+
- Windows Web smoke confirmed `/api/health`, `/api/codex`, Plan Approval UI, compact header copy tools, and one-line Codex session copy layout.
|
|
36
|
+
|
|
3
37
|
## v0.0.14
|
|
4
38
|
|
|
5
39
|
### Highlights
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -41,20 +41,20 @@
|
|
|
41
41
|
@media (prefers-reduced-motion: reduce) { button, button.state-active, button.state-pending::after, .refresh-pulse-chip.pulse .refresh-pulse-dot { animation: none !important; transition: none !important; } }
|
|
42
42
|
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
43
43
|
th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
|
|
44
|
-
th:nth-child(1), td:nth-child(1) { width:
|
|
44
|
+
th:nth-child(1), td:nth-child(1) { width: 32%; }
|
|
45
45
|
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
46
46
|
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
47
47
|
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
48
48
|
th:nth-child(5), td:nth-child(5) { width: 96px; }
|
|
49
|
-
th:nth-child(6), td:nth-child(6) { width:
|
|
49
|
+
th:nth-child(6), td:nth-child(6) { width: 116px; }
|
|
50
50
|
th:nth-child(7), td:nth-child(7) { width: 66px; }
|
|
51
51
|
th:nth-child(8), td:nth-child(8) { width: 94px; }
|
|
52
52
|
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
53
53
|
tr:hover { background: #162033; cursor: pointer; }
|
|
54
54
|
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
55
|
-
.completed, .judged, .
|
|
55
|
+
.completed, .judged, .planned, .batches_completed { background: var(--green); }
|
|
56
56
|
.running, .planning, .judging { background: var(--blue); }
|
|
57
|
-
.failed, .
|
|
57
|
+
.failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
58
58
|
.plan_empty { background: var(--orange); }
|
|
59
59
|
.pending, .created { background: var(--gray); }
|
|
60
60
|
.batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
|
|
@@ -117,7 +117,9 @@
|
|
|
117
117
|
.run-card-title-actions { display: inline-flex; align-items: center; gap: 3px; }
|
|
118
118
|
.archive-confirm-btn { min-width: 46px; padding: 4px 10px; border-color: rgba(248,113,113,.85); background: var(--red) !important; color: white; font-weight: 900; }
|
|
119
119
|
.icon-svg { width: 14px; height: 14px; display: block; }
|
|
120
|
-
.session-cell {
|
|
120
|
+
.session-cell-wrap { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
|
121
|
+
.session-cell { min-width: 0; font-variant-numeric: tabular-nums; }
|
|
122
|
+
.session-cell-wrap .copy-btn { flex: 0 0 auto; margin-left: 0; }
|
|
121
123
|
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
122
124
|
.row-actions .danger, .row-actions .secondary { margin: 0; }
|
|
123
125
|
.status-stack { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 5px; }
|
|
@@ -139,11 +141,47 @@
|
|
|
139
141
|
.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); }
|
|
140
142
|
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
141
143
|
.floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
|
|
142
|
-
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; align-items:
|
|
144
|
+
.modal-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; align-items: flex-start; justify-content: center; padding: 12px 20px 20px; background: rgba(2,6,23,.72); overflow: auto; }
|
|
143
145
|
.modal-backdrop.hidden { display: none; }
|
|
144
|
-
.modal-card { width: min(
|
|
146
|
+
.modal-card { width: min(960px, 100%); max-height: min(88vh, 860px); border: 1px solid var(--line); border-radius: 14px; background: var(--panel); box-shadow: 0 18px 60px rgba(0,0,0,.38); padding: 12px 16px 14px; overflow: hidden; display: flex; flex-direction: column; margin-top: 0; }
|
|
147
|
+
.modal-card.resizable { resize: vertical; }
|
|
145
148
|
.modal-card textarea { min-height: 220px; }
|
|
149
|
+
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
150
|
+
.modal-title-row h2 { margin: 0; }
|
|
151
|
+
.modal-title-actions { display: inline-flex; align-items: center; gap: 10px; }
|
|
152
|
+
.modal-title-meta { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
153
|
+
.modal-close-btn { padding: 3px 9px; font-size: 18px; line-height: 1; opacity: .72; }
|
|
154
|
+
.modal-close-btn:hover { opacity: 1; }
|
|
146
155
|
.page-footer { padding: 0 18px 18px; color: var(--muted); text-align: center; font-size: 12px; }
|
|
156
|
+
.session-management-trigger { margin-top: 8px; padding: 3px 8px; font-size: 12px; opacity: .72; }
|
|
157
|
+
.session-management-trigger:hover { opacity: 1; }
|
|
158
|
+
.session-management-tabs { display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap; align-items: center; }
|
|
159
|
+
.session-management-tab { padding: 5px 9px; font-size: 12px; background: var(--gray); }
|
|
160
|
+
.session-management-tab.active { background: var(--blue); }
|
|
161
|
+
.session-management-load { margin-top: 10px; color: var(--muted); font-size: 12px; }
|
|
162
|
+
.session-management-body { display: flex; flex: 1; min-height: 0; flex-direction: column; margin-top: 2px; overflow: auto; }
|
|
163
|
+
.session-management-list { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; min-height: 0; }
|
|
164
|
+
.session-management-item { padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: #0b1324; }
|
|
165
|
+
.session-management-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: flex-start; }
|
|
166
|
+
.session-management-main { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
|
167
|
+
.session-management-title { min-width: 0; font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(640px, 66vw); }
|
|
168
|
+
.session-management-side { display: inline-flex; align-items: center; gap: 8px; min-width: 0; justify-self: end; }
|
|
169
|
+
.session-management-id { min-width: 0; font-variant-numeric: tabular-nums; word-break: break-all; color: var(--muted); }
|
|
170
|
+
.session-management-status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
|
171
|
+
.session-management-status .pill { padding: 2px 7px; font-size: 11px; }
|
|
172
|
+
.session-management-meta { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
173
|
+
.session-management-empty { padding: 12px 0; color: var(--muted); }
|
|
174
|
+
.process-management { margin-top: 8px; border-top: 1px solid var(--line); padding-top: 8px; }
|
|
175
|
+
.process-management.hidden { display: none; }
|
|
176
|
+
.process-management-title { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
|
|
177
|
+
.process-management-group { margin-top: 4px; }
|
|
178
|
+
.process-management-group-title { display: flex; justify-content: space-between; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; margin: 4px 0; }
|
|
179
|
+
.process-management-list { display: flex; flex-direction: column; gap: 8px; }
|
|
180
|
+
.process-management-item { padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #081120; }
|
|
181
|
+
.process-management-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
182
|
+
.process-management-resume { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
|
|
183
|
+
.process-management-resume .pill { padding: 2px 7px; font-size: 11px; }
|
|
184
|
+
.modal-card h2:first-child { margin-top: 0; }
|
|
147
185
|
</style>
|
|
148
186
|
</head>
|
|
149
187
|
<body>
|
|
@@ -157,7 +195,7 @@
|
|
|
157
195
|
</div>
|
|
158
196
|
<div class="workspace-filter-panel">
|
|
159
197
|
<select id="workspaceFilterSelect" class="workspace-filter-select" onchange="setWorkspaceFilter(this.value)" title="未筛选工作区"></select>
|
|
160
|
-
<span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载"
|
|
198
|
+
<span id="runsLoadHint" class="runs-load-icon" title="批次列表尚未加载" aria-label="批次列表尚未加载">i</span>
|
|
161
199
|
</div>
|
|
162
200
|
<div id="runs" class="run-list"><div class="empty">批次列表加载中...</div></div>
|
|
163
201
|
</section>
|
|
@@ -188,7 +226,7 @@
|
|
|
188
226
|
<h2>批次详情</h2>
|
|
189
227
|
<div class="build-header">
|
|
190
228
|
<div id="selected" class="muted">未选择任务批次</div>
|
|
191
|
-
<div id="autoRefreshHint" class="muted hidden"
|
|
229
|
+
<div id="autoRefreshHint" class="muted hidden">自动刷新:未启动</div>
|
|
192
230
|
<div id="runNotice" class="notice warning hidden"></div>
|
|
193
231
|
<div id="actionToolbar" class="toolbar"></div>
|
|
194
232
|
</div>
|
|
@@ -200,7 +238,7 @@
|
|
|
200
238
|
<section id="filePanel" class="log-panel">
|
|
201
239
|
<div class="section-header">
|
|
202
240
|
<h2>任务详情</h2>
|
|
203
|
-
<span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied
|
|
241
|
+
<span class="info-icon" title="若执行过程提示 Permission denied / sandbox denied,这通常不是任务本身失败,而是当前 worker 沙箱能力不足;在可信工作区可改用 danger-full-access。DNS / 网络失败则通常需要检查代理、VPN 或本地 evidence。" aria-label="任务详情权限与网络提示">i</span>
|
|
204
242
|
</div>
|
|
205
243
|
<div id="fileTitle" class="muted">点击任务后查看详情</div>
|
|
206
244
|
<div id="fileTabs" class="toolbar file-tabs"></div>
|
|
@@ -212,14 +250,34 @@
|
|
|
212
250
|
</section>
|
|
213
251
|
</div>
|
|
214
252
|
</main>
|
|
215
|
-
<footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div></footer>
|
|
253
|
+
<footer class="page-footer"><div id="pageFooter">版本:-</div><div id="codexStatus" class="codex-status hidden"></div><button id="sessionManagementTrigger" class="secondary session-management-trigger" onclick="openSessionManagement()">会话管理</button></footer>
|
|
254
|
+
<div id="sessionManagementModal" class="modal-backdrop hidden" onclick="closeSessionManagement(event)">
|
|
255
|
+
<div id="sessionManagementModalCard" class="modal-card" onclick="event.stopPropagation()">
|
|
256
|
+
<div class="modal-title-row">
|
|
257
|
+
<h2>会话管理</h2>
|
|
258
|
+
<div class="modal-title-actions"><span id="sessionManagementTotalMemory" class="modal-title-meta"></span><button class="secondary modal-close-btn" title="关闭" aria-label="关闭" onclick="closeSessionManagement()">×</button></div>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="session-management-tabs">
|
|
261
|
+
<button id="sessionManagementTabBoard" class="secondary session-management-tab active" onclick="setSessionManagementTab('board')">看板</button>
|
|
262
|
+
<button id="sessionManagementTabLocal" class="secondary session-management-tab" onclick="setSessionManagementTab('local')">本机</button>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="session-management-body">
|
|
265
|
+
<div id="sessionManagementLoad" class="session-management-load">尚未加载</div>
|
|
266
|
+
<div id="sessionManagementList" class="session-management-list"></div>
|
|
267
|
+
<div id="processManagementPanel" class="process-management hidden">
|
|
268
|
+
<div class="process-management-title"><span>本机 Codex 进程</span><span id="processManagementLoad">尚未加载</span></div>
|
|
269
|
+
<div id="processManagementList" class="process-management-list"></div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
216
274
|
<div id="manualCompleteModal" class="modal-backdrop hidden">
|
|
217
275
|
<div class="modal-card">
|
|
218
276
|
<h2>手动标记成功</h2>
|
|
219
277
|
<div id="manualCompleteTitle" class="muted"></div>
|
|
220
278
|
<label>人工成功执行结果</label>
|
|
221
279
|
<textarea id="manualCompleteResult" placeholder="粘贴 codex resume 后的最终回复、验证结果、关键证据或 artifact 路径"></textarea>
|
|
222
|
-
<div class="muted">该内容会保存为 manual_result.md
|
|
280
|
+
<div class="muted">该内容会保存为 manual_result.md,显示在"结果"中,并作为 final judge 的人工成功证据。</div>
|
|
223
281
|
<div class="toolbar">
|
|
224
282
|
<button onclick="submitManualComplete()">确认标记成功</button>
|
|
225
283
|
<button class="secondary" onclick="closeManualCompleteModal()">取消</button>
|
|
@@ -235,6 +293,11 @@ let pendingArchiveRunId = null;
|
|
|
235
293
|
let pendingAction = null;
|
|
236
294
|
let currentState = null;
|
|
237
295
|
let lastAutoRefreshAt = null;
|
|
296
|
+
let sessionManagementOpen = false;
|
|
297
|
+
let sessionManagementTab = 'board';
|
|
298
|
+
const SESSION_MODAL_HEIGHT_STORAGE_KEY = 'input-kanban.sessionManagementHeight';
|
|
299
|
+
let sessionManagementState = { loading: false, threads: [], total: 0, error: '' };
|
|
300
|
+
let processManagementState = { loading: false, processes: [], total: 0, error: '' };
|
|
238
301
|
let runListVisibleCount = 10;
|
|
239
302
|
let latestRuns = [];
|
|
240
303
|
let workspaceCatalogRuns = [];
|
|
@@ -273,7 +336,6 @@ function userFacingErrorMessage(error) {
|
|
|
273
336
|
const statusText = {
|
|
274
337
|
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
275
338
|
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
276
|
-
workers_completed: '子任务完成', workers_failed: '子任务失败',
|
|
277
339
|
batches_completed: '批次完成', batch_blocked: '批次阻塞', plan_empty: '拆分为空', stopped: '已停止',
|
|
278
340
|
judging: '验收中', judged: '已验收', plan_failed: '拆分失败', judge_failed: '验收失败'
|
|
279
341
|
};
|
|
@@ -313,7 +375,7 @@ function runDurationEnd(s) {
|
|
|
313
375
|
...(s.tasks || []).flatMap(task => [task.endedAt, task.completedAt, task.stoppedAt])
|
|
314
376
|
]) || s.updatedAt;
|
|
315
377
|
}
|
|
316
|
-
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}
|
|
378
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${formatDurationMs(durationSeconds(s.createdAt, runDurationEnd(s)) * 1000)}`; }
|
|
317
379
|
function basenamePath(value) {
|
|
318
380
|
const parts = String(value || '').split(/[\\/]/).filter(Boolean);
|
|
319
381
|
return parts.at(-1) || value || '-';
|
|
@@ -388,14 +450,14 @@ async function loadCodexStatus() {
|
|
|
388
450
|
el.classList.remove('hidden', 'warning');
|
|
389
451
|
if (!codex.installed) {
|
|
390
452
|
el.classList.add('warning');
|
|
391
|
-
el.innerHTML = `Codex
|
|
453
|
+
el.innerHTML = `Codex 未安装|<code>${esc(codex.installCommand || 'npm install -g @openai/codex')}</code>`;
|
|
392
454
|
} else {
|
|
393
455
|
el.innerHTML = `<code>${esc(codex.versionText || codex.installedVersion || 'codex')}</code>`;
|
|
394
456
|
}
|
|
395
457
|
} catch (error) {
|
|
396
458
|
el.classList.remove('hidden');
|
|
397
459
|
el.classList.add('warning');
|
|
398
|
-
el.innerHTML = `Codex
|
|
460
|
+
el.innerHTML = `Codex:检测失败|${esc(errorDetail(error))}`;
|
|
399
461
|
}
|
|
400
462
|
}
|
|
401
463
|
function showCreateForm() {
|
|
@@ -473,7 +535,7 @@ function renderRunList() {
|
|
|
473
535
|
</div>
|
|
474
536
|
</div>`);
|
|
475
537
|
if (latestRuns.length > runListVisibleCount) {
|
|
476
|
-
cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()"
|
|
538
|
+
cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()">查看更多(${runListVisibleCount}/${latestRuns.length})</button>`);
|
|
477
539
|
}
|
|
478
540
|
document.getElementById('runs').innerHTML = latestRuns.length ? cards.join('') : '<div class="empty">暂无任务批次</div>';
|
|
479
541
|
}
|
|
@@ -512,7 +574,7 @@ function updateWorkspaceFilterTitle() {
|
|
|
512
574
|
if (selectedWorkspaceFilter === WORKSPACE_FILTER_ALL) {
|
|
513
575
|
select.title = currentWorkspacePath ? `未筛选工作区|默认工作区:${currentWorkspacePath}` : '未筛选工作区';
|
|
514
576
|
} else {
|
|
515
|
-
select.title =
|
|
577
|
+
select.title = `当前筛选:${selectedWorkspaceFilter}`;
|
|
516
578
|
}
|
|
517
579
|
}
|
|
518
580
|
function setWorkspaceFilter(value) {
|
|
@@ -535,11 +597,12 @@ async function refreshSelected({auto=false} = {}) {
|
|
|
535
597
|
await loadTaskDescription();
|
|
536
598
|
renderTasks(); await refreshRuns();
|
|
537
599
|
if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
|
|
600
|
+
if (sessionManagementOpen) await refreshSessionManagement();
|
|
538
601
|
}
|
|
539
602
|
function renderSelectedHeader() {
|
|
540
603
|
if (!currentState) return '<div class="muted">未选择任务批次</div>';
|
|
541
604
|
const sandbox = currentState.workerSandbox || 'workspace-write';
|
|
542
|
-
const titleActions = [`<button class="secondary copy-btn title-copy-btn" title="复制 Run ID
|
|
605
|
+
const titleActions = [`<button class="secondary copy-btn title-copy-btn" title="复制 Run ID:${esc(currentState.runId)}" data-copy-kind="run-id" onclick="copyRunId(event)">${titleCopyLabel('run-id')}</button>`];
|
|
543
606
|
if (currentState.runner === 'tmux' && hasRunTmuxMetadata(currentState)) {
|
|
544
607
|
titleActions.push(`<button class="secondary copy-btn title-copy-btn" title="复制 tmux attach 指令" data-copy-kind="tmux" onclick="copyTmuxRunCommand(event)">${titleCopyLabel('tmux')}</button>`);
|
|
545
608
|
}
|
|
@@ -565,7 +628,7 @@ async function loadTaskDescription() {
|
|
|
565
628
|
}
|
|
566
629
|
function refreshPulseChip() {
|
|
567
630
|
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
568
|
-
return `<span id="refreshPulse" class="refresh-pulse-chip" title="
|
|
631
|
+
return `<span id="refreshPulse" class="refresh-pulse-chip" title="自动刷新:每 ${AUTO_REFRESH_MS / 1000} 秒;上次 ${esc(last)}"><span class="refresh-pulse-dot"></span></span>`;
|
|
569
632
|
}
|
|
570
633
|
function actionButton({ key, label, onclick, variant = '', state = '', disabled = false, title = '' }) {
|
|
571
634
|
const classes = [variant, state ? `state-${state}` : ''].filter(Boolean).join(' ');
|
|
@@ -575,34 +638,33 @@ function runActionState(key) {
|
|
|
575
638
|
if (!selectedRun || !currentState) return { label: '-', disabled: true, state: '' };
|
|
576
639
|
if (pendingAction === key) {
|
|
577
640
|
return {
|
|
578
|
-
plan: { label: '
|
|
579
|
-
dispatch: { label: '
|
|
580
|
-
judge: { label: '
|
|
581
|
-
stop: { label: '
|
|
582
|
-
archive: { label: '
|
|
641
|
+
plan: { label: '拆分中...', disabled: true, state: 'pending' },
|
|
642
|
+
dispatch: { label: '启动中...', disabled: true, state: 'pending' },
|
|
643
|
+
judge: { label: '验收中...', disabled: true, state: 'pending' },
|
|
644
|
+
stop: { label: '停止中...', disabled: true, state: 'pending' },
|
|
645
|
+
archive: { label: '归档中...', disabled: true, state: 'pending' }
|
|
583
646
|
}[key];
|
|
584
647
|
}
|
|
585
648
|
const status = currentState.status;
|
|
586
649
|
const anyWorkerRunning = (currentState.tasks || []).some(t => t.status === 'running');
|
|
587
650
|
if (key === 'plan') {
|
|
588
651
|
if (status === 'planning' || currentState.planner?.status === 'running') return { label: '拆分中', disabled: true, state: 'active' };
|
|
589
|
-
if (status === 'planned' || status === 'running' || status === '
|
|
652
|
+
if (status === 'planned' || status === 'running' || status === 'batches_completed' || status === 'judging' || status === 'judged') return { label: '已拆分', disabled: true, state: 'done' };
|
|
590
653
|
if (status === 'plan_failed' || status === 'plan_empty') return { label: '重试拆分', disabled: false, state: 'retry' };
|
|
591
654
|
return { label: '拆分', disabled: false, state: '' };
|
|
592
655
|
}
|
|
593
656
|
if (key === 'dispatch') {
|
|
594
657
|
if (status === 'running' || anyWorkerRunning) return { label: '执行中', disabled: true, state: 'active' };
|
|
595
658
|
if (status === 'planned') return { label: planApprovalPending() ? '开始执行' : '执行', disabled: false, state: '' };
|
|
596
|
-
if (status === 'batch_blocked') return { label: '
|
|
597
|
-
if (
|
|
598
|
-
if (['workers_completed','batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
659
|
+
if (status === 'batch_blocked') return { label: '重试执行', disabled: false, state: 'retry' };
|
|
660
|
+
if (['batches_completed','judging','judged'].includes(status)) return { label: '已完成', disabled: true, state: 'done' };
|
|
599
661
|
return { label: '执行', disabled: true, state: 'done' };
|
|
600
662
|
}
|
|
601
663
|
if (key === 'judge') {
|
|
602
664
|
if (status === 'judging' || currentState.judge?.status === 'running') return { label: '验收中', disabled: true, state: 'active' };
|
|
603
665
|
if (status === 'judged') return { label: '已验收', disabled: true, state: 'done' };
|
|
604
666
|
if (status === 'judge_failed') return { label: '重试验收', disabled: false, state: 'retry' };
|
|
605
|
-
if (status === 'batches_completed'
|
|
667
|
+
if (status === 'batches_completed') return { label: '验收', disabled: false, state: '' };
|
|
606
668
|
return { label: '验收', disabled: true, state: 'done' };
|
|
607
669
|
}
|
|
608
670
|
if (key === 'stop') {
|
|
@@ -666,6 +728,204 @@ function triggerRefreshPulse() {
|
|
|
666
728
|
void el.offsetWidth;
|
|
667
729
|
el.classList.add('pulse');
|
|
668
730
|
}
|
|
731
|
+
function sessionManagementStateLabel(thread) {
|
|
732
|
+
const started = formatDateTime(thread?.startedAt);
|
|
733
|
+
const updated = formatDateTime(thread?.updatedAt);
|
|
734
|
+
return `创建 ${started}|最后更新 ${updated}`;
|
|
735
|
+
}
|
|
736
|
+
function sessionManagementShortId(sessionId) {
|
|
737
|
+
const text = String(sessionId || '');
|
|
738
|
+
return text.length > 10 ? `…${text.slice(-10)}` : text || '-';
|
|
739
|
+
}
|
|
740
|
+
function sessionManagementThreadTitle(thread) {
|
|
741
|
+
const title = typeof thread?.title === 'string' ? thread.title.trim() : '';
|
|
742
|
+
const previewLine = String(thread?.preview || '').split(/\r?\n/).map(line => line.trim()).find(line => line && !/^ORCHESTRATOR_[A-Z_]+:\s*/.test(line)) || '';
|
|
743
|
+
return title || previewLine || String(thread?.sessionId || '').trim() || '-';
|
|
744
|
+
}
|
|
745
|
+
function sessionManagementTabLabel(tab) {
|
|
746
|
+
return tab === 'board' ? '看板' : '本机';
|
|
747
|
+
}
|
|
748
|
+
function formatProcessMemory(value) {
|
|
749
|
+
const number = Number(value);
|
|
750
|
+
return Number.isFinite(number) ? `${number.toFixed(number >= 10 ? 0 : 1)} MB` : '-';
|
|
751
|
+
}
|
|
752
|
+
function formatProcessElapsed(processInfo) {
|
|
753
|
+
return processInfo?.elapsed || '-';
|
|
754
|
+
}
|
|
755
|
+
function setSessionManagementTab(tab) {
|
|
756
|
+
sessionManagementTab = tab === 'local' ? 'local' : 'board';
|
|
757
|
+
renderSessionManagementList();
|
|
758
|
+
renderProcessManagementList();
|
|
759
|
+
}
|
|
760
|
+
function restoreSessionManagementHeight() {
|
|
761
|
+
const modalCard = document.getElementById('sessionManagementModalCard');
|
|
762
|
+
const saved = localStorage.getItem(SESSION_MODAL_HEIGHT_STORAGE_KEY);
|
|
763
|
+
if (!modalCard?.style) return;
|
|
764
|
+
modalCard.style.height = saved || '78vh';
|
|
765
|
+
}
|
|
766
|
+
function saveSessionManagementHeight() {
|
|
767
|
+
const modalCard = document.getElementById('sessionManagementModalCard');
|
|
768
|
+
if (!modalCard?.style || typeof getComputedStyle !== 'function') return;
|
|
769
|
+
const height = getComputedStyle(modalCard).height;
|
|
770
|
+
if (height) localStorage.setItem(SESSION_MODAL_HEIGHT_STORAGE_KEY, height);
|
|
771
|
+
}
|
|
772
|
+
function renderSessionManagementList() {
|
|
773
|
+
const list = document.getElementById('sessionManagementList');
|
|
774
|
+
const hint = document.getElementById('sessionManagementLoad');
|
|
775
|
+
const boardTab = document.getElementById('sessionManagementTabBoard');
|
|
776
|
+
const localTab = document.getElementById('sessionManagementTabLocal');
|
|
777
|
+
if (!list || !hint || !boardTab || !localTab) return;
|
|
778
|
+
boardTab.classList.toggle('active', sessionManagementTab === 'board');
|
|
779
|
+
localTab.classList.toggle('active', sessionManagementTab === 'local');
|
|
780
|
+
if (!sessionManagementOpen) return;
|
|
781
|
+
if (sessionManagementState.loading) {
|
|
782
|
+
hint.textContent = '正在加载…';
|
|
783
|
+
list.innerHTML = '<div class="session-management-empty">正在加载会话列表…</div>';
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (sessionManagementState.error) {
|
|
787
|
+
hint.textContent = `加载失败:${sessionManagementState.error}`;
|
|
788
|
+
list.innerHTML = '<div class="session-management-empty">会话列表加载失败</div>';
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
hint.textContent = '';
|
|
792
|
+
const threads = (sessionManagementState.threads || []).filter(thread => sessionManagementTab === 'board' ? !!thread.boardManaged : !thread.boardManaged);
|
|
793
|
+
if (!threads.length) {
|
|
794
|
+
list.innerHTML = '';
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
list.innerHTML = threads.map(thread => `
|
|
798
|
+
<div class="session-management-item">
|
|
799
|
+
<div class="session-management-row">
|
|
800
|
+
<div class="session-management-main">
|
|
801
|
+
<span class="session-management-title" title="${esc(sessionManagementThreadTitle(thread))}">${esc(sessionManagementThreadTitle(thread))}</span>
|
|
802
|
+
</div>
|
|
803
|
+
<div class="session-management-side">
|
|
804
|
+
<span class="session-management-id" title="${esc(thread.sessionId)}">${esc(sessionManagementShortId(thread.sessionId))}</span>
|
|
805
|
+
<button class="copy-btn" title="复制完整会话ID" onclick='copySessionManagementId(event, ${JSON.stringify(thread.sessionId)})'>⧉</button>
|
|
806
|
+
${thread.status && thread.status !== 'unknown' ? `<span class="session-management-status"><span class="pill ${esc(thread.status || '')}">${esc(thread.status || '-')}</span></span>` : ''}
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
<div class="session-management-meta">
|
|
810
|
+
${metaChip('创建', formatDateTime(thread.startedAt))}
|
|
811
|
+
${metaChip('最后更新', formatDateTime(thread.updatedAt))}
|
|
812
|
+
${metaChip('来源', thread.source || '-')}
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
`).join('');
|
|
816
|
+
}
|
|
817
|
+
function renderProcessManagementList() {
|
|
818
|
+
const panel = document.getElementById('processManagementPanel');
|
|
819
|
+
const list = document.getElementById('processManagementList');
|
|
820
|
+
const load = document.getElementById('processManagementLoad');
|
|
821
|
+
if (!panel || !list || !load) return;
|
|
822
|
+
panel.classList.toggle('hidden', sessionManagementTab !== 'local');
|
|
823
|
+
if (sessionManagementTab !== 'local') return;
|
|
824
|
+
if (processManagementState.loading) {
|
|
825
|
+
load.textContent = '正在加载…';
|
|
826
|
+
list.innerHTML = '<div class="session-management-empty">正在扫描本机 Codex 进程…</div>';
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (processManagementState.error) {
|
|
830
|
+
load.textContent = `加载失败:${processManagementState.error}`;
|
|
831
|
+
list.innerHTML = '<div class="session-management-empty">进程列表加载失败</div>';
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const processes = processManagementState.processes || [];
|
|
835
|
+
const resumableProcesses = processes.filter(processInfo => processInfo.sessionId);
|
|
836
|
+
const otherProcesses = processes.filter(processInfo => !processInfo.sessionId);
|
|
837
|
+
const totalMemoryMb = processes.reduce((sum, processInfo) => sum + (Number(processInfo.rssMb) || 0), 0);
|
|
838
|
+
const totalMemory = document.getElementById('sessionManagementTotalMemory');
|
|
839
|
+
if (totalMemory) totalMemory.textContent = `总占用内存 ${formatProcessMemory(totalMemoryMb)}`;
|
|
840
|
+
load.textContent = `${resumableProcesses.length || 0} 个可 resume 进程|${otherProcesses.length || 0} 个其他进程`;
|
|
841
|
+
if (!processes.length) {
|
|
842
|
+
list.innerHTML = '';
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const renderProcessCard = processInfo => `
|
|
846
|
+
<div class="process-management-item">
|
|
847
|
+
<div class="process-management-row">
|
|
848
|
+
<div class="session-management-main">
|
|
849
|
+
${metaChip('PID', processInfo.pid, { title: processInfo.command || '' })}
|
|
850
|
+
${metaChip('父进程', processInfo.ppid)}
|
|
851
|
+
${metaChip('内存', formatProcessMemory(processInfo.rssMb))}
|
|
852
|
+
${metaChip('CPU', `${processInfo.cpuPercent || 0}%`)}
|
|
853
|
+
${metaChip('运行', formatProcessElapsed(processInfo))}
|
|
854
|
+
</div>
|
|
855
|
+
${processInfo.sessionId ? `<span class="process-management-resume"><span class="pill">resume</span><button class="secondary copy-btn" title="复制会话ID" onclick='copySessionId(event, ${JSON.stringify(processInfo.sessionId)})'>⧉</button></span>` : ''}
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
`;
|
|
859
|
+
list.innerHTML = `
|
|
860
|
+
${resumableProcesses.length ? `<div class="process-management-group"><div class="process-management-group-title"><span>可 resume</span><span>${resumableProcesses.length}</span></div><div class="process-management-list">${resumableProcesses.map(renderProcessCard).join('')}</div></div>` : ''}
|
|
861
|
+
${otherProcesses.length ? `<div class="process-management-group"><div class="process-management-group-title"><span>其他 Codex 进程</span><span>${otherProcesses.length}</span></div><div class="process-management-list">${otherProcesses.map(renderProcessCard).join('')}</div></div>` : ''}
|
|
862
|
+
`;
|
|
863
|
+
}
|
|
864
|
+
async function loadProcessManagement() {
|
|
865
|
+
processManagementState.loading = true;
|
|
866
|
+
processManagementState.error = '';
|
|
867
|
+
renderProcessManagementList();
|
|
868
|
+
try {
|
|
869
|
+
const data = await api('/api/session-management/processes');
|
|
870
|
+
processManagementState = { loading: false, processes: data.processes || [], total: data.total || 0, error: '' };
|
|
871
|
+
} catch (error) {
|
|
872
|
+
processManagementState = { loading: false, processes: [], total: 0, error: errorDetail(error) };
|
|
873
|
+
}
|
|
874
|
+
renderProcessManagementList();
|
|
875
|
+
}
|
|
876
|
+
async function loadSessionManagement() {
|
|
877
|
+
const load = document.getElementById('sessionManagementLoad');
|
|
878
|
+
if (!load) return;
|
|
879
|
+
sessionManagementState.loading = true;
|
|
880
|
+
sessionManagementState.error = '';
|
|
881
|
+
renderSessionManagementList();
|
|
882
|
+
try {
|
|
883
|
+
const data = await api('/api/session-management?limit=100');
|
|
884
|
+
sessionManagementState = { loading: false, threads: data.threads || [], total: data.total || 0, error: '' };
|
|
885
|
+
} catch (error) {
|
|
886
|
+
sessionManagementState = { loading: false, threads: [], total: 0, error: errorDetail(error) };
|
|
887
|
+
}
|
|
888
|
+
renderSessionManagementList();
|
|
889
|
+
}
|
|
890
|
+
async function openSessionManagement() {
|
|
891
|
+
sessionManagementOpen = true;
|
|
892
|
+
const modal = document.getElementById('sessionManagementModal');
|
|
893
|
+
modal.classList.remove('hidden');
|
|
894
|
+
restoreSessionManagementHeight();
|
|
895
|
+
await Promise.all([loadSessionManagement(), loadProcessManagement()]);
|
|
896
|
+
}
|
|
897
|
+
function closeSessionManagement(event) {
|
|
898
|
+
if (event?.target && event.target !== event.currentTarget) return;
|
|
899
|
+
sessionManagementOpen = false;
|
|
900
|
+
saveSessionManagementHeight();
|
|
901
|
+
document.getElementById('sessionManagementModal').classList.add('hidden');
|
|
902
|
+
}
|
|
903
|
+
async function refreshSessionManagement() {
|
|
904
|
+
if (!sessionManagementOpen) return;
|
|
905
|
+
await Promise.all([loadSessionManagement(), loadProcessManagement()]);
|
|
906
|
+
}
|
|
907
|
+
async function copySessionId(event, sessionId) {
|
|
908
|
+
event.stopPropagation();
|
|
909
|
+
try {
|
|
910
|
+
await navigator.clipboard.writeText(sessionId);
|
|
911
|
+
event.currentTarget.textContent = '✓';
|
|
912
|
+
setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
|
|
913
|
+
} catch {
|
|
914
|
+
prompt('复制 Codex 会话ID', sessionId);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function initSessionManagementResize() {
|
|
918
|
+
const card = document.getElementById('sessionManagementModalCard');
|
|
919
|
+
if (!card) return;
|
|
920
|
+
card.classList.add('resizable');
|
|
921
|
+
if (card.style) {
|
|
922
|
+
card.style.height = localStorage.getItem(SESSION_MODAL_HEIGHT_STORAGE_KEY) || '78vh';
|
|
923
|
+
card.style.minHeight = '380px';
|
|
924
|
+
}
|
|
925
|
+
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
|
926
|
+
window.addEventListener('mouseup', saveSessionManagementHeight);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
669
929
|
function updateRunNotice() {
|
|
670
930
|
const el = document.getElementById('runNotice');
|
|
671
931
|
if (!el) return;
|
|
@@ -677,12 +937,12 @@ function updateRunNotice() {
|
|
|
677
937
|
const error = currentState.planner?.planParseError;
|
|
678
938
|
if (currentState.status === 'plan_empty') {
|
|
679
939
|
el.classList.remove('hidden');
|
|
680
|
-
el.textContent = `Planner
|
|
940
|
+
el.textContent = `Planner 已完成,但没有拆出任何任务。可以调整任务说明或直接再次点击"拆分任务"重试。${error ? `原因:${error}` : ''}`;
|
|
681
941
|
return;
|
|
682
942
|
}
|
|
683
943
|
if (currentState.status === 'plan_failed' && error) {
|
|
684
944
|
el.classList.remove('hidden');
|
|
685
|
-
el.textContent = `Planner
|
|
945
|
+
el.textContent = `Planner 拆分失败。可以再次点击"拆分任务"重试。原因:${error}`;
|
|
686
946
|
return;
|
|
687
947
|
}
|
|
688
948
|
el.classList.add('hidden');
|
|
@@ -691,10 +951,10 @@ function updateRunNotice() {
|
|
|
691
951
|
function updateAutoRefreshHint() {
|
|
692
952
|
const el = document.getElementById('autoRefreshHint');
|
|
693
953
|
if (!el) return;
|
|
694
|
-
if (!selectedRun || !currentState) { el.textContent = '
|
|
954
|
+
if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
|
|
695
955
|
const active = ['planning','running','judging','planned','batches_completed','batch_blocked'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
|
|
696
956
|
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
697
|
-
el.textContent = active ?
|
|
957
|
+
el.textContent = active ? `自动模式中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新并推进一次|上次刷新 ${last}` : `自动模式待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
|
|
698
958
|
}
|
|
699
959
|
function taskStatusCell(t) {
|
|
700
960
|
if (t?.manualCompletion) {
|
|
@@ -708,7 +968,7 @@ function taskActionCell(id, t) {
|
|
|
708
968
|
if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
|
|
709
969
|
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
710
970
|
const pending = pendingAction === `manual:${id}`;
|
|
711
|
-
return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '
|
|
971
|
+
return `<button class="danger ${pending ? 'state-pending' : ''}"${pending ? ' disabled' : ''} onclick="markTaskCompleted(event, '${id}')">${pending ? '标记中...' : '手动标记成功'}</button>`;
|
|
712
972
|
}
|
|
713
973
|
function shortSessionId(thread) {
|
|
714
974
|
const text = String(thread || '');
|
|
@@ -716,7 +976,7 @@ function shortSessionId(thread) {
|
|
|
716
976
|
}
|
|
717
977
|
function sessionCell(thread) {
|
|
718
978
|
if (!thread) return '-';
|
|
719
|
-
return `<span class="session-cell" title="${esc(thread)}"
|
|
979
|
+
return `<span class="session-cell-wrap"><span class="session-cell" title="${esc(thread)}">...${esc(shortSessionId(thread))}</span><button class="copy-btn" title="复制完整 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button></span>`;
|
|
720
980
|
}
|
|
721
981
|
function taskStartedCell(t) {
|
|
722
982
|
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
@@ -1049,11 +1309,15 @@ async function renameRunLabel(event, runId = selectedRun) {
|
|
|
1049
1309
|
});
|
|
1050
1310
|
}
|
|
1051
1311
|
async function planRun() { if (selectedRun) await runActionWithPending('plan', async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
1052
|
-
async function dispatchRun() {
|
|
1312
|
+
async function dispatchRun() {
|
|
1313
|
+
if (!selectedRun) return;
|
|
1314
|
+
const endpoint = currentState?.status === 'batch_blocked' ? 'retry' : 'dispatch';
|
|
1315
|
+
await runActionWithPending('dispatch', async () => { await api(`/api/runs/${selectedRun}/${endpoint}`, {method:'POST'}); await refreshSelected(); });
|
|
1316
|
+
}
|
|
1053
1317
|
async function judgeRun() { if (selectedRun) await runActionWithPending('judge', async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
1054
1318
|
async function stopSelectedRun() {
|
|
1055
1319
|
if (!selectedRun) return;
|
|
1056
|
-
const ok = confirm('
|
|
1320
|
+
const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
|
|
1057
1321
|
if (!ok) return;
|
|
1058
1322
|
await runActionWithPending('stop', async () => {
|
|
1059
1323
|
await api(`/api/runs/${selectedRun}/stop`, {
|
|
@@ -1081,7 +1345,7 @@ async function archiveRunFromCard(event, runId) {
|
|
|
1081
1345
|
async function archiveRunById(runId, { confirmFirst = true } = {}) {
|
|
1082
1346
|
if (!runId) return;
|
|
1083
1347
|
if (confirmFirst) {
|
|
1084
|
-
const ok = confirm('
|
|
1348
|
+
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
1085
1349
|
if (!ok) return;
|
|
1086
1350
|
}
|
|
1087
1351
|
await runActionWithPending('archive', async () => {
|
|
@@ -1108,7 +1372,7 @@ async function markTaskCompleted(event, taskId) {
|
|
|
1108
1372
|
event.stopPropagation();
|
|
1109
1373
|
if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
|
|
1110
1374
|
const task = (currentState?.tasks || []).find(t => t.id === taskId);
|
|
1111
|
-
if (task?.status === 'running') { alert('
|
|
1375
|
+
if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
|
|
1112
1376
|
manualCompleteTaskId = taskId;
|
|
1113
1377
|
document.getElementById('manualCompleteTitle').textContent = `${selectedRun} / ${taskId}`;
|
|
1114
1378
|
document.getElementById('manualCompleteResult').value = '';
|
|
@@ -1137,6 +1401,7 @@ async function submitManualComplete() {
|
|
|
1137
1401
|
}
|
|
1138
1402
|
|
|
1139
1403
|
initializeWorkerSandboxPreference();
|
|
1404
|
+
initSessionManagementResize();
|
|
1140
1405
|
renderActionToolbar();
|
|
1141
1406
|
loadCodexStatus().catch(console.error);
|
|
1142
1407
|
loadHealth().then(refreshRuns);
|
package/src/orchestrator.js
CHANGED
|
@@ -500,6 +500,27 @@ async function rotatePlannerAttempt(state, runDir) {
|
|
|
500
500
|
}];
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
async function rotateJudgeAttempt(state, runDir) {
|
|
504
|
+
const judgeDir = roleDir(runDir, 'judge');
|
|
505
|
+
if (!fs.existsSync(judgeDir)) return;
|
|
506
|
+
const attemptsDir = path.join(runDir, 'judge_attempts');
|
|
507
|
+
await ensureDir(attemptsDir);
|
|
508
|
+
const previousAttempts = (state.judgeAttempts || []).map(item => Number(item.attempt || 0)).filter(Number.isFinite);
|
|
509
|
+
const attempt = Number(state.judge?.attempt || 0) || Math.max(1, 1 + Math.max(0, ...previousAttempts));
|
|
510
|
+
const archivedDir = path.join(attemptsDir, `attempt-${String(attempt).padStart(2, '0')}`);
|
|
511
|
+
await fsp.rm(archivedDir, { recursive: true, force: true });
|
|
512
|
+
await fsp.rename(judgeDir, archivedDir);
|
|
513
|
+
state.judgeAttempts = [...(state.judgeAttempts || []), {
|
|
514
|
+
attempt,
|
|
515
|
+
status: state.judge?.status,
|
|
516
|
+
exitCode: state.judge?.exitCode ?? null,
|
|
517
|
+
startedAt: state.judge?.startedAt,
|
|
518
|
+
endedAt: state.judge?.endedAt,
|
|
519
|
+
archivedDir,
|
|
520
|
+
archivedAt: nowIso()
|
|
521
|
+
}];
|
|
522
|
+
}
|
|
523
|
+
|
|
503
524
|
async function rotateWorkerAttempt(state, task) {
|
|
504
525
|
const runDir = pathForRun(state.runId);
|
|
505
526
|
const workerDir = roleDir(runDir, 'worker', task.id);
|
|
@@ -792,16 +813,26 @@ export async function startJudge(runId) {
|
|
|
792
813
|
return await withRunStateLock(runId, async () => {
|
|
793
814
|
const state = await loadRun(runId);
|
|
794
815
|
if (!state) throw new Error(`run not found: ${runId}`);
|
|
816
|
+
if (state.archived) throw new Error('archived run cannot be judged');
|
|
817
|
+
if (state.status === 'stopped') throw new Error('stopped run cannot be judged');
|
|
795
818
|
recomputeRunStatus(state);
|
|
819
|
+
if (state.judge?.status === 'running') throw new Error('judge already running');
|
|
820
|
+
if (state.judge?.status === 'completed') throw new Error('judge already completed');
|
|
796
821
|
if (!allBatchesCompleted(state) && state.tasks?.length) throw new Error('final judge is allowed only after all batches completed');
|
|
797
|
-
const
|
|
822
|
+
const runDir = pathForRun(runId);
|
|
823
|
+
if (state.judge?.status === 'failed') await rotateJudgeAttempt(state, runDir);
|
|
824
|
+
const outDir = roleDir(runDir, 'judge');
|
|
798
825
|
await ensureDir(outDir);
|
|
799
826
|
const judgeInputPath = path.join(outDir, 'judge_input.json');
|
|
800
827
|
const judgeInput = await buildJudgeInput(state);
|
|
801
828
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
802
829
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
803
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(
|
|
804
|
-
|
|
830
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
831
|
+
const previousJudgeAttempts = [
|
|
832
|
+
...(state.judgeAttempts || []).map(item => Number(item.attempt || 0)),
|
|
833
|
+
Number(state.judge?.attempt || 0)
|
|
834
|
+
].filter(Number.isFinite);
|
|
835
|
+
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: 1 + Math.max(0, ...previousJudgeAttempts) };
|
|
805
836
|
state.status = 'judging';
|
|
806
837
|
await saveRun(state);
|
|
807
838
|
child.onExit(async code => {
|
package/src/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
2
3
|
import fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
import { CodexAppServerClient } from './appServerClient.js';
|
|
6
8
|
import { APP_ROOT, CODEX_BIN, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR, detectCodexInfo } from './utils.js';
|
|
@@ -8,6 +10,9 @@ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun,
|
|
|
8
10
|
import { startAutoScheduler } from './scheduler.js';
|
|
9
11
|
|
|
10
12
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
13
|
+
const CODEX_INFO_TTL_MS = 30000;
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
let codexInfoCache = null;
|
|
11
16
|
|
|
12
17
|
function send(res, status, body, type = 'application/json') {
|
|
13
18
|
const data = type === 'application/json' ? JSON.stringify(body, null, 2) : body;
|
|
@@ -26,6 +31,157 @@ async function readBody(req) {
|
|
|
26
31
|
function notFound(res) { send(res, 404, { error: 'not found' }); }
|
|
27
32
|
function methodNotAllowed(res) { send(res, 405, { error: 'method not allowed' }); }
|
|
28
33
|
|
|
34
|
+
async function cachedCodexInfo(nowMs = Date.now()) {
|
|
35
|
+
if (codexInfoCache && codexInfoCache.expiresAt > nowMs) return codexInfoCache.value;
|
|
36
|
+
const value = await detectCodexInfo();
|
|
37
|
+
codexInfoCache = { value, expiresAt: nowMs + CODEX_INFO_TTL_MS };
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function threadTimeValue(thread, keys) {
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const value = thread?.[key];
|
|
44
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasAppServerThreadMarkers(thread) {
|
|
50
|
+
const preview = String(thread?.preview || '');
|
|
51
|
+
return preview.includes('ORCHESTRATOR_RUN_ID: ') && preview.includes('ORCHESTRATOR_TASK_ID: ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function previewThreadTitle(preview) {
|
|
55
|
+
const lines = String(preview || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (/^ORCHESTRATOR_[A-Z_]+:\s*/.test(line)) continue;
|
|
58
|
+
if (/^[A-Z0-9_]+:\s*/.test(line)) continue;
|
|
59
|
+
if (line === '{' || line === '}' || line === '[' || line === ']') continue;
|
|
60
|
+
return line;
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeStringField(value) {
|
|
66
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeThreadStatus(thread) {
|
|
70
|
+
const rawStatus = thread?.status;
|
|
71
|
+
if (typeof rawStatus === 'string' && rawStatus.trim()) return rawStatus.trim();
|
|
72
|
+
if (rawStatus && typeof rawStatus === 'object') {
|
|
73
|
+
const candidate = rawStatus.label || rawStatus.name || rawStatus.state || rawStatus.status || rawStatus.text;
|
|
74
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
|
75
|
+
}
|
|
76
|
+
const fallback = normalizeStringField(thread?.state);
|
|
77
|
+
return fallback || 'unknown';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeThreadSource(thread) {
|
|
81
|
+
const rawSource = thread?.source;
|
|
82
|
+
if (typeof rawSource === 'string' && rawSource.trim()) return rawSource.trim();
|
|
83
|
+
if (rawSource && typeof rawSource === 'object') {
|
|
84
|
+
const candidate = rawSource.label || rawSource.name || rawSource.kind || rawSource.source;
|
|
85
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
|
86
|
+
}
|
|
87
|
+
return normalizeStringField(thread?.sourceKind) || normalizeStringField(thread?.source_kind) || '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeAppServerThread(thread) {
|
|
91
|
+
const sessionId = String(thread?.sessionId || thread?.session_id || thread?.id || thread?.threadId || '').trim();
|
|
92
|
+
const startedAt = threadTimeValue(thread, ['startedAt', 'started_at', 'createdAt', 'created_at', 'createdTime', 'created_time']);
|
|
93
|
+
const updatedAt = threadTimeValue(thread, ['updatedAt', 'updated_at', 'modifiedAt', 'modified_at', 'lastActiveAt', 'last_active_at']) || startedAt;
|
|
94
|
+
const previewTitle = previewThreadTitle(thread?.preview);
|
|
95
|
+
const rawTitle = normalizeStringField(thread?.title) || normalizeStringField(thread?.name) || normalizeStringField(thread?.subject);
|
|
96
|
+
const title = rawTitle || previewTitle || '会话';
|
|
97
|
+
return {
|
|
98
|
+
id: String(thread?.id || sessionId || '').trim(),
|
|
99
|
+
title,
|
|
100
|
+
sessionId: sessionId || String(thread?.id || '').trim(),
|
|
101
|
+
status: normalizeThreadStatus(thread),
|
|
102
|
+
source: normalizeThreadSource(thread),
|
|
103
|
+
startedAt,
|
|
104
|
+
updatedAt,
|
|
105
|
+
boardManaged: hasAppServerThreadMarkers(thread)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sortThreadListDesc(left, right) {
|
|
110
|
+
const leftMs = Date.parse(left.updatedAt || left.startedAt || '') || 0;
|
|
111
|
+
const rightMs = Date.parse(right.updatedAt || right.startedAt || '') || 0;
|
|
112
|
+
return rightMs - leftMs || String(right.sessionId || '').localeCompare(String(left.sessionId || ''));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parsePsElapsedSeconds(value) {
|
|
116
|
+
const text = String(value || '').trim();
|
|
117
|
+
if (!text) return null;
|
|
118
|
+
const dayParts = text.split('-');
|
|
119
|
+
const days = dayParts.length === 2 ? Number(dayParts[0]) || 0 : 0;
|
|
120
|
+
const timeText = dayParts.at(-1) || '';
|
|
121
|
+
const parts = timeText.split(':').map(part => Number(part) || 0);
|
|
122
|
+
if (parts.length === 3) return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
123
|
+
if (parts.length === 2) return days * 86400 + parts[0] * 60 + parts[1];
|
|
124
|
+
return days * 86400 + (parts[0] || 0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isCodexRelatedCommand(command) {
|
|
128
|
+
const text = String(command || '').toLowerCase();
|
|
129
|
+
if (!text.includes('codex')) return false;
|
|
130
|
+
return /(^|[\s/.-])codex([\s/.-]|$)/.test(text) || text.includes('@openai/codex') || text.includes('codex-cli') || text.includes('app-server');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function listCodexProcesses() {
|
|
134
|
+
if (process.platform === 'win32') {
|
|
135
|
+
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', `Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,WorkingSetSize,CreationDate | ConvertTo-Json -Compress`], { maxBuffer: 1024 * 1024 });
|
|
136
|
+
const parsed = stdout ? JSON.parse(stdout) : [];
|
|
137
|
+
const processes = (Array.isArray(parsed) ? parsed : [parsed])
|
|
138
|
+
.map(item => {
|
|
139
|
+
const command = String(item?.CommandLine || '').trim();
|
|
140
|
+
if (!isCodexRelatedCommand(command)) return null;
|
|
141
|
+
const sessionMatch = command.match(/\bcodex\s+resume\s+([A-Za-z0-9._:-]+)\b/i);
|
|
142
|
+
return {
|
|
143
|
+
pid: Number(item?.ProcessId) || 0,
|
|
144
|
+
ppid: Number(item?.ParentProcessId) || 0,
|
|
145
|
+
cpuPercent: 0,
|
|
146
|
+
memoryPercent: 0,
|
|
147
|
+
rssMb: Math.round(((Number(item?.WorkingSetSize) || 0) / 1024 / 1024) * 10) / 10,
|
|
148
|
+
elapsed: '',
|
|
149
|
+
elapsedSeconds: null,
|
|
150
|
+
command,
|
|
151
|
+
sessionId: sessionMatch?.[1] || '',
|
|
152
|
+
isSelf: Number(item?.ProcessId) === process.pid
|
|
153
|
+
};
|
|
154
|
+
})
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.sort((left, right) => right.rssMb - left.rssMb || left.pid - right.pid);
|
|
157
|
+
return processes;
|
|
158
|
+
}
|
|
159
|
+
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,pcpu=,pmem=,rss=,etime=,command='], { maxBuffer: 1024 * 1024 });
|
|
160
|
+
const processes = stdout.split(/\r?\n/)
|
|
161
|
+
.map(line => {
|
|
162
|
+
const match = String(line || '').match(/^\s*(\d+)\s+(\d+)\s+([0-9.]+)\s+([0-9.]+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
163
|
+
if (!match) return null;
|
|
164
|
+
const [, pid, ppid, cpu, mem, rssKb, elapsed, command] = match;
|
|
165
|
+
const sessionMatch = command.match(/\bcodex\s+resume\s+([A-Za-z0-9._:-]+)\b/i);
|
|
166
|
+
return {
|
|
167
|
+
pid: Number(pid),
|
|
168
|
+
ppid: Number(ppid),
|
|
169
|
+
cpuPercent: Number(cpu) || 0,
|
|
170
|
+
memoryPercent: Number(mem) || 0,
|
|
171
|
+
rssMb: Math.round(((Number(rssKb) || 0) / 1024) * 10) / 10,
|
|
172
|
+
elapsed,
|
|
173
|
+
elapsedSeconds: parsePsElapsedSeconds(elapsed),
|
|
174
|
+
command: command.trim(),
|
|
175
|
+
sessionId: sessionMatch?.[1] || '',
|
|
176
|
+
isSelf: Number(pid) === process.pid
|
|
177
|
+
};
|
|
178
|
+
})
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.filter(processInfo => isCodexRelatedCommand(processInfo.command))
|
|
181
|
+
.sort((left, right) => right.rssMb - left.rssMb || left.pid - right.pid);
|
|
182
|
+
return processes;
|
|
183
|
+
}
|
|
184
|
+
|
|
29
185
|
async function serveStatic(req, res, pathname) {
|
|
30
186
|
let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
|
|
31
187
|
file = path.resolve(file);
|
|
@@ -46,7 +202,17 @@ async function handleApi(req, res, url, appClient) {
|
|
|
46
202
|
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER, codexBin: CODEX_BIN });
|
|
47
203
|
}
|
|
48
204
|
if (req.method === 'GET' && url.pathname === '/api/codex') {
|
|
49
|
-
return send(res, 200, { ok: true, codex: await
|
|
205
|
+
return send(res, 200, { ok: true, codex: await cachedCodexInfo() });
|
|
206
|
+
}
|
|
207
|
+
if (req.method === 'GET' && url.pathname === '/api/session-management') {
|
|
208
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 100) || 100));
|
|
209
|
+
const response = await appClient.listThreads({ limit });
|
|
210
|
+
const threads = (response?.data || []).map(normalizeAppServerThread).filter(thread => thread.sessionId).sort(sortThreadListDesc);
|
|
211
|
+
return send(res, 200, { ok: true, threads, total: threads.length });
|
|
212
|
+
}
|
|
213
|
+
if (req.method === 'GET' && url.pathname === '/api/session-management/processes') {
|
|
214
|
+
const processes = await listCodexProcesses();
|
|
215
|
+
return send(res, 200, { ok: true, processes, total: processes.length });
|
|
50
216
|
}
|
|
51
217
|
if (parts[1] === 'runs' && parts.length === 2) {
|
|
52
218
|
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
|