input-kanban 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ENVIRONMENT.md +39 -0
- package/PROJECT_GUIDE.md +331 -0
- package/README.md +157 -0
- package/bin/input-kanban.js +71 -0
- package/package.json +40 -0
- package/public/index.html +492 -0
- package/src/appServerClient.js +90 -0
- package/src/orchestrator.js +734 -0
- package/src/server.js +111 -0
- package/src/utils.js +78 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Codex 编排看板</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --bg:#0b1220; --panel:#111827; --panel-2:#0f172a; --line:#334155; --line-strong:#64748b; --text:#e2e8f0; --muted:#94a3b8; --blue:#2563eb; --green:#166534; --red:#991b1b; --gray:#475569; --orange:#b45309; }
|
|
9
|
+
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
|
10
|
+
header { padding: 18px 24px; background: #0a1020; border-bottom: 1px solid var(--line); box-shadow: 0 1px 0 rgba(255,255,255,.03); }
|
|
11
|
+
h1 { margin: 0; font-size: 22px; letter-spacing: .2px; }
|
|
12
|
+
h2 { margin: 0 0 12px; font-size: 24px; }
|
|
13
|
+
h3 { margin: 18px 0 8px; font-size: 15px; color: #cbd5e1; }
|
|
14
|
+
main { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 18px; padding: 18px; align-items: start; min-height: calc(100vh - 59px); }
|
|
15
|
+
main > div, section { min-width: 0; }
|
|
16
|
+
.sidebar { position: sticky; top: 18px; height: calc(100vh - 36px); }
|
|
17
|
+
.sidebar section { height: 100%; display: flex; flex-direction: column; box-sizing: border-box; }
|
|
18
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
|
|
19
|
+
textarea, input { width: 100%; box-sizing: border-box; background: #020617; color: var(--text); border: 1px solid #475569; border-radius: 9px; padding: 9px 10px; outline: none; }
|
|
20
|
+
textarea:focus, input:focus { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(37,99,235,.25); }
|
|
21
|
+
textarea { min-height: 240px; }
|
|
22
|
+
label { display: block; margin-top: 10px; color: #cbd5e1; font-weight: 700; }
|
|
23
|
+
button { background: var(--blue); color: white; border: 0; border-radius: 9px; padding: 8px 11px; margin: 4px 4px 4px 0; cursor: pointer; font-weight: 700; }
|
|
24
|
+
button:hover { filter: brightness(1.08); }
|
|
25
|
+
button.secondary { background: var(--gray); }
|
|
26
|
+
button.danger { background: #dc2626; }
|
|
27
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
|
|
28
|
+
th, td { border-bottom: 1px solid var(--line); padding: 9px 8px; text-align: left; vertical-align: top; }
|
|
29
|
+
th:nth-child(1), td:nth-child(1) { width: 34%; }
|
|
30
|
+
th:nth-child(2), td:nth-child(2) { width: 92px; white-space: nowrap; }
|
|
31
|
+
th:nth-child(3), td:nth-child(3) { width: 132px; }
|
|
32
|
+
th:nth-child(4), td:nth-child(4) { width: 64px; white-space: nowrap; }
|
|
33
|
+
th:nth-child(5), td:nth-child(5) { width: 70px; }
|
|
34
|
+
th:nth-child(6), td:nth-child(6) { width: 58px; }
|
|
35
|
+
th:nth-child(8), td:nth-child(8) { width: 66px; }
|
|
36
|
+
th:nth-child(9), td:nth-child(9) { width: 94px; }
|
|
37
|
+
th { color: #cbd5e1; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
|
|
38
|
+
tr:hover { background: #162033; cursor: pointer; }
|
|
39
|
+
.pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: 800; background: var(--gray); line-height: 1.3; }
|
|
40
|
+
.completed, .judged, .workers_completed, .planned, .batches_completed { background: var(--green); }
|
|
41
|
+
.running, .planning, .judging { background: var(--blue); }
|
|
42
|
+
.failed, .workers_failed, .plan_failed, .judge_failed, .unknown, .batch_blocked { background: var(--red); }
|
|
43
|
+
.plan_empty { background: var(--orange); }
|
|
44
|
+
.pending, .created { background: var(--gray); }
|
|
45
|
+
.batch-row td { border-top: 3px solid var(--line-strong); background: #101827; font-weight: 800; font-size: 14px; padding-top: 13px; }
|
|
46
|
+
.job-row.selected { background: rgba(37,99,235,.16); box-shadow: inset 3px 0 0 #60a5fa; }
|
|
47
|
+
.task-name { min-width: 0; overflow: hidden; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
48
|
+
.task-role { display: block; margin-top: 2px; }
|
|
49
|
+
pre { white-space: pre-wrap; overflow: auto; overflow-wrap: anywhere; word-break: break-word; max-width: 100%; max-height: 460px; box-sizing: border-box; background: #020617; border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
|
50
|
+
.muted { color: var(--muted); font-size: 12px; }
|
|
51
|
+
.hidden { display: none; }
|
|
52
|
+
.toolbar { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
53
|
+
.task-text { max-height: 180px; color: #cbd5e1; }
|
|
54
|
+
.empty { color: var(--muted); padding: 18px 0; }
|
|
55
|
+
.run-list { display: flex; flex-direction: column; gap: 10px; flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
|
|
56
|
+
.run-list-more { width: 100%; margin-top: 4px; }
|
|
57
|
+
.run-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: var(--panel-2); cursor: pointer; transition: border-color .15s, transform .15s, background .15s; }
|
|
58
|
+
.run-card:hover { border-color: var(--line-strong); background: #162033; transform: translateY(-1px); }
|
|
59
|
+
.run-card.active { border-color: #60a5fa; box-shadow: inset 3px 0 0 #60a5fa; }
|
|
60
|
+
.run-card-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: flex-start; font-weight: 800; }
|
|
61
|
+
.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; }
|
|
62
|
+
.run-card-title .pill { flex: 0 0 auto; }
|
|
63
|
+
.run-card-id { margin-top: 5px; word-break: break-all; }
|
|
64
|
+
.run-card-created { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
|
65
|
+
.run-card-progress { margin-top: 8px; display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
|
|
66
|
+
.build-header { border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel-2); margin-bottom: 14px; }
|
|
67
|
+
.build-title { display: flex; align-items: center; gap: 10px; font-size: 22px; font-weight: 900; }
|
|
68
|
+
.build-meta { margin-top: 6px; color: var(--muted); word-break: break-all; }
|
|
69
|
+
.log-panel { margin-top: 16px; }
|
|
70
|
+
.file-tabs button { font-size: 13px; }
|
|
71
|
+
.copy-btn { padding: 2px 6px; margin: 0 0 0 6px; border-radius: 6px; font-size: 12px; line-height: 1.2; background: var(--gray); vertical-align: middle; }
|
|
72
|
+
.session-cell { word-break: break-all; }
|
|
73
|
+
.execution-summary { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 -2px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px 10px 0 0; background: #020617; color: var(--muted); font-size: 12px; }
|
|
74
|
+
.execution-summary.hidden { display: none; }
|
|
75
|
+
.execution-summary + pre { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
76
|
+
.notice { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: #1f2937; color: #cbd5e1; }
|
|
77
|
+
.notice.warning { border-color: #92400e; background: rgba(180,83,9,.18); }
|
|
78
|
+
.file-content-wrap { position: relative; }
|
|
79
|
+
.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); }
|
|
80
|
+
.file-content-wrap:hover .floating-copy-btn:not(.hidden), .floating-copy-btn:focus { opacity: 1; pointer-events: auto; }
|
|
81
|
+
.floating-copy-btn:not(.hidden) + pre { padding-top: 42px; }
|
|
82
|
+
</style>
|
|
83
|
+
</head>
|
|
84
|
+
<body>
|
|
85
|
+
<header><h1>Codex 编排看板</h1></header>
|
|
86
|
+
<main>
|
|
87
|
+
<div class="sidebar">
|
|
88
|
+
<section>
|
|
89
|
+
<div class="toolbar">
|
|
90
|
+
<button onclick="showCreateForm()">新建任务批次</button>
|
|
91
|
+
<button class="secondary" onclick="refreshRuns()">刷新批次列表</button>
|
|
92
|
+
</div>
|
|
93
|
+
<h2>任务批次</h2>
|
|
94
|
+
<div id="runs" class="run-list"></div>
|
|
95
|
+
</section>
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<section id="createPanel" class="hidden">
|
|
99
|
+
<h2>新建任务批次</h2>
|
|
100
|
+
<label>批次名称</label><input id="label" value="codex-task" />
|
|
101
|
+
<label>目标仓库</label><input id="repo" />
|
|
102
|
+
<label>运行目录</label><input id="runsDir" readonly />
|
|
103
|
+
<label>最大并发数</label><input id="maxParallel" type="number" value="3" min="1" max="16" />
|
|
104
|
+
<label>任务说明</label><textarea id="taskText" placeholder="粘贴任务说明"></textarea>
|
|
105
|
+
<div class="toolbar">
|
|
106
|
+
<button onclick="createRun()">创建批次</button>
|
|
107
|
+
<button class="secondary" onclick="hideCreateForm()">取消</button>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section id="detailPanel">
|
|
112
|
+
<h2>批次详情</h2>
|
|
113
|
+
<div class="build-header">
|
|
114
|
+
<div id="selected" class="muted">未选择任务批次</div>
|
|
115
|
+
<div id="autoRefreshHint" class="muted">自动刷新:未启动</div>
|
|
116
|
+
<div id="runNotice" class="notice warning hidden"></div>
|
|
117
|
+
<div class="toolbar">
|
|
118
|
+
<button onclick="planRun()">拆分任务</button>
|
|
119
|
+
<button onclick="dispatchRun()">派发执行</button>
|
|
120
|
+
<button onclick="judgeRun()">汇总验收</button>
|
|
121
|
+
<button class="secondary" onclick="refreshSelected()">刷新状态</button>
|
|
122
|
+
<button class="secondary" onclick="stopSelectedRun()">停止</button>
|
|
123
|
+
<button class="danger" onclick="archiveSelectedRun()">归档</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<h3>任务说明</h3>
|
|
127
|
+
<pre id="taskDescription" class="task-text">未选择任务批次</pre>
|
|
128
|
+
<div id="tasks"></div>
|
|
129
|
+
</section>
|
|
130
|
+
|
|
131
|
+
<section id="filePanel" class="log-panel">
|
|
132
|
+
<h2>文件查看</h2>
|
|
133
|
+
<div id="fileTitle" class="muted">点击任务后选择文件</div>
|
|
134
|
+
<div class="toolbar file-tabs">
|
|
135
|
+
<button class="secondary" onclick="loadFile('prompt.md')">提示词</button>
|
|
136
|
+
<button class="secondary" onclick="loadFile('events.jsonl')">事件日志</button>
|
|
137
|
+
<button class="secondary" onclick="loadFile('events.pretty')">执行过程</button>
|
|
138
|
+
<button class="secondary" onclick="loadFile('stderr.log')">错误日志</button>
|
|
139
|
+
<button class="secondary" onclick="loadFile('last_message.md')">最终回复</button>
|
|
140
|
+
<button class="secondary" onclick="loadFile('exit_code')">退出码</button>
|
|
141
|
+
<button class="secondary" onclick="loadFile('result.json')">结果</button>
|
|
142
|
+
<button class="secondary" onclick="loadFile('evidence.json')">证据</button>
|
|
143
|
+
<button class="secondary" onclick="loadFile('judge_input.json')">验收输入</button>
|
|
144
|
+
<button class="secondary" onclick="loadFile('verdict.json')">验收结论</button>
|
|
145
|
+
</div>
|
|
146
|
+
<div id="executionSummary" class="execution-summary hidden"></div>
|
|
147
|
+
<div class="file-content-wrap">
|
|
148
|
+
<button id="copyLastMessageBtn" class="secondary copy-btn floating-copy-btn hidden" title="复制最终回复内容" onclick="copyFileContent(event)">⧉</button>
|
|
149
|
+
<pre id="fileContent"></pre>
|
|
150
|
+
</div>
|
|
151
|
+
</section>
|
|
152
|
+
</div>
|
|
153
|
+
</main>
|
|
154
|
+
<script>
|
|
155
|
+
let selectedRun = null;
|
|
156
|
+
let selectedTask = null;
|
|
157
|
+
let selectedFileName = null;
|
|
158
|
+
let currentState = null;
|
|
159
|
+
let lastAutoRefreshAt = null;
|
|
160
|
+
let runListVisibleCount = 10;
|
|
161
|
+
let latestRuns = [];
|
|
162
|
+
const AUTO_REFRESH_MS = 3000;
|
|
163
|
+
const RUN_LIST_PAGE_SIZE = 10;
|
|
164
|
+
|
|
165
|
+
async function api(path, opts={}) {
|
|
166
|
+
const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts });
|
|
167
|
+
if (!res.ok) throw new Error(await res.text());
|
|
168
|
+
const ct = res.headers.get('content-type') || '';
|
|
169
|
+
return ct.includes('application/json') ? res.json() : res.text();
|
|
170
|
+
}
|
|
171
|
+
const statusText = {
|
|
172
|
+
created: '已创建', pending: '等待中', planning: '拆分中', planned: '已拆分',
|
|
173
|
+
running: '执行中', completed: '已完成', failed: '失败', unknown: '未知',
|
|
174
|
+
workers_completed: '子任务完成', workers_failed: '子任务失败',
|
|
175
|
+
batches_completed: '批次完成', batch_blocked: '批次阻塞', plan_empty: '拆分为空', stopped: '已停止',
|
|
176
|
+
judging: '验收中', judged: '已验收', plan_failed: '拆分失败', judge_failed: '验收失败'
|
|
177
|
+
};
|
|
178
|
+
const roleText = { planner: '任务拆分', judge: '汇总验收' };
|
|
179
|
+
function displayStatus(s) { return statusText[s] || s || '-'; }
|
|
180
|
+
function displayRole(s) { return roleText[s] || s || '-'; }
|
|
181
|
+
function pill(s) { return `<span class="pill ${s || ''}">${displayStatus(s)}</span>`; }
|
|
182
|
+
function esc(s) { return String(s ?? '').replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
183
|
+
function formatDateTime(s) {
|
|
184
|
+
if (!s) return '-';
|
|
185
|
+
const date = new Date(s);
|
|
186
|
+
const pad = n => String(n).padStart(2, '0');
|
|
187
|
+
return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
188
|
+
}
|
|
189
|
+
function durationSeconds(start, end) {
|
|
190
|
+
if (!start) return '-';
|
|
191
|
+
const endMs = end ? new Date(end).getTime() : Date.now();
|
|
192
|
+
return Math.max(0, Math.round((endMs - new Date(start).getTime()) / 1000));
|
|
193
|
+
}
|
|
194
|
+
function runTimingText(s) { return `开始时刻 ${formatDateTime(s.createdAt)}|用时 ${durationSeconds(s.createdAt, s.updatedAt)} 秒`; }
|
|
195
|
+
|
|
196
|
+
async function loadHealth() {
|
|
197
|
+
const h = await api('/api/health');
|
|
198
|
+
document.getElementById('repo').value = h.defaultRepo;
|
|
199
|
+
document.getElementById('runsDir').value = h.runsDir;
|
|
200
|
+
}
|
|
201
|
+
function showCreateForm() {
|
|
202
|
+
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
203
|
+
clearFileView();
|
|
204
|
+
document.getElementById('createPanel').classList.remove('hidden');
|
|
205
|
+
document.getElementById('detailPanel').classList.add('hidden');
|
|
206
|
+
document.getElementById('filePanel').classList.add('hidden');
|
|
207
|
+
}
|
|
208
|
+
function hideCreateForm() {
|
|
209
|
+
document.getElementById('createPanel').classList.add('hidden');
|
|
210
|
+
document.getElementById('detailPanel').classList.remove('hidden');
|
|
211
|
+
document.getElementById('filePanel').classList.remove('hidden');
|
|
212
|
+
}
|
|
213
|
+
async function createRun() {
|
|
214
|
+
const body = { label: label.value, repo: repo.value, maxParallel: maxParallel.value, taskText: taskText.value };
|
|
215
|
+
const r = await api('/api/runs', { method: 'POST', body: JSON.stringify(body) });
|
|
216
|
+
selectedRun = r.runId; selectedTask = null; selectedFileName = null;
|
|
217
|
+
clearFileView();
|
|
218
|
+
hideCreateForm();
|
|
219
|
+
await refreshRuns(); await refreshSelected();
|
|
220
|
+
}
|
|
221
|
+
async function refreshRuns() {
|
|
222
|
+
const data = await api('/api/runs');
|
|
223
|
+
latestRuns = data.runs || [];
|
|
224
|
+
renderRunList();
|
|
225
|
+
}
|
|
226
|
+
function renderRunList() {
|
|
227
|
+
const visibleRuns = latestRuns.slice(0, runListVisibleCount);
|
|
228
|
+
const cards = visibleRuns.map(r => `
|
|
229
|
+
<div class="run-card ${selectedRun === r.runId ? 'active' : ''}" onclick="selectRun('${r.runId}')">
|
|
230
|
+
<div class="run-card-title"><span class="run-card-name" title="${esc(r.label)}">${esc(r.label)}</span>${pill(r.status)}</div>
|
|
231
|
+
<div class="run-card-id muted">${esc(r.runId)}</div>
|
|
232
|
+
<div class="run-card-created">创建 ${esc(formatDateTime(r.createdAt))}</div>
|
|
233
|
+
<div class="run-card-progress"><span>进度 ${r.completed}/${r.total}</span><span>执行中 ${r.running}|失败 ${r.failed}</span></div>
|
|
234
|
+
</div>`);
|
|
235
|
+
if (latestRuns.length > runListVisibleCount) {
|
|
236
|
+
cards.push(`<button class="secondary run-list-more" onclick="showMoreRuns()">查看更多(${runListVisibleCount}/${latestRuns.length})</button>`);
|
|
237
|
+
}
|
|
238
|
+
document.getElementById('runs').innerHTML = latestRuns.length ? cards.join('') : '<div class="empty">暂无任务批次</div>';
|
|
239
|
+
}
|
|
240
|
+
function showMoreRuns() {
|
|
241
|
+
runListVisibleCount += RUN_LIST_PAGE_SIZE;
|
|
242
|
+
renderRunList();
|
|
243
|
+
}
|
|
244
|
+
async function selectRun(id) { selectedRun = id; selectedTask = null; selectedFileName = null; clearFileView(); hideCreateForm(); await refreshSelected(); }
|
|
245
|
+
async function refreshSelected({auto=false} = {}) {
|
|
246
|
+
if (!selectedRun) return;
|
|
247
|
+
currentState = await api(`/api/runs/${selectedRun}/status`);
|
|
248
|
+
if (auto) lastAutoRefreshAt = new Date();
|
|
249
|
+
document.getElementById('selected').innerHTML = `<div class="build-title"><span>${esc(currentState.label)}</span>${pill(currentState.status)}</div><div class="build-meta">${esc(currentState.runId)}|仓库=${esc(currentState.repo)}<br>${esc(runTimingText(currentState))}</div>`;
|
|
250
|
+
updateAutoRefreshHint();
|
|
251
|
+
updateRunNotice();
|
|
252
|
+
await loadTaskDescription();
|
|
253
|
+
renderTasks(); await refreshRuns();
|
|
254
|
+
if (selectedTask && selectedFileName) await loadFile(selectedFileName, { preserveScroll: true });
|
|
255
|
+
}
|
|
256
|
+
async function loadTaskDescription() {
|
|
257
|
+
if (!selectedRun) { document.getElementById('taskDescription').textContent = '未选择任务批次'; return; }
|
|
258
|
+
document.getElementById('taskDescription').textContent = await api(`/api/runs/${selectedRun}/task-text`);
|
|
259
|
+
}
|
|
260
|
+
function updateRunNotice() {
|
|
261
|
+
const el = document.getElementById('runNotice');
|
|
262
|
+
if (!el) return;
|
|
263
|
+
if (!currentState) {
|
|
264
|
+
el.classList.add('hidden');
|
|
265
|
+
el.textContent = '';
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const error = currentState.planner?.planParseError;
|
|
269
|
+
if (currentState.status === 'plan_empty') {
|
|
270
|
+
el.classList.remove('hidden');
|
|
271
|
+
el.textContent = `Planner 已完成,但没有拆出任何任务。可以调整任务说明或直接再次点击“拆分任务”重试。${error ? `原因:${error}` : ''}`;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (currentState.status === 'plan_failed' && error) {
|
|
275
|
+
el.classList.remove('hidden');
|
|
276
|
+
el.textContent = `Planner 拆分失败。可以再次点击“拆分任务”重试。原因:${error}`;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
el.classList.add('hidden');
|
|
280
|
+
el.textContent = '';
|
|
281
|
+
}
|
|
282
|
+
function updateAutoRefreshHint() {
|
|
283
|
+
const el = document.getElementById('autoRefreshHint');
|
|
284
|
+
if (!el) return;
|
|
285
|
+
if (!selectedRun || !currentState) { el.textContent = '自动刷新:未启动'; return; }
|
|
286
|
+
const active = ['planning','running','judging'].includes(currentState.status) || (currentState.tasks || []).some(t => t.status === 'running') || currentState.planner?.status === 'running' || currentState.judge?.status === 'running';
|
|
287
|
+
const last = lastAutoRefreshAt ? lastAutoRefreshAt.toLocaleTimeString() : '尚未触发';
|
|
288
|
+
el.textContent = active ? `自动刷新中:每 ${AUTO_REFRESH_MS / 1000} 秒刷新一次|上次刷新 ${last}` : `自动刷新待命:每 ${AUTO_REFRESH_MS / 1000} 秒检查一次|上次刷新 ${last}`;
|
|
289
|
+
}
|
|
290
|
+
function taskStatusCell(t) {
|
|
291
|
+
if (t?.manualCompletion) {
|
|
292
|
+
const original = t.originalStatus || t.manualCompletion.originalStatus || t.manualCompletion.previousStatus || 'unknown';
|
|
293
|
+
return `${pill(original)} <span class="pill completed">手动标记成功已完成</span>`;
|
|
294
|
+
}
|
|
295
|
+
return pill(t?.status);
|
|
296
|
+
}
|
|
297
|
+
function taskActionCell(id, t) {
|
|
298
|
+
if (!t || id === 'planner' || id === 'judge') return '-';
|
|
299
|
+
if (t.manualCompletion) return '<span class="muted">已人工确认</span>';
|
|
300
|
+
if (!['unknown', 'failed'].includes(t.status)) return '-';
|
|
301
|
+
return `<button class="danger" onclick="markTaskCompleted(event, '${id}')">手动标记成功</button>`;
|
|
302
|
+
}
|
|
303
|
+
function sessionCell(thread) {
|
|
304
|
+
if (!thread) return '-';
|
|
305
|
+
return `<span class="session-cell">${esc(thread)}</span><button class="copy-btn" title="复制 Codex 会话ID" onclick="copySessionId(event, '${esc(thread)}')">⧉</button>`;
|
|
306
|
+
}
|
|
307
|
+
function taskStartedCell(t) {
|
|
308
|
+
return t?.startedAt ? formatDateTime(t.startedAt) : '-';
|
|
309
|
+
}
|
|
310
|
+
function taskDurationCell(t) {
|
|
311
|
+
if (!t?.startedAt) return '-';
|
|
312
|
+
const end = t.endedAt || t.completedAt || t.stoppedAt || (t.status === 'running' ? null : t.updatedAt);
|
|
313
|
+
return `${durationSeconds(t.startedAt, end)} 秒`;
|
|
314
|
+
}
|
|
315
|
+
function taskRow(id, role, t) {
|
|
316
|
+
const thread = t?.codexThread?.id || '';
|
|
317
|
+
return `<tr class="job-row ${selectedTask === id ? 'selected' : ''}" onclick="selectTask('${id}')"><td><b class="task-name" title="${esc(id)}">${esc(id)}</b><span class="muted task-role">${esc(displayRole(role))}</span></td><td>${taskStatusCell(t)}</td><td>${esc(taskStartedCell(t))}</td><td>${esc(taskDurationCell(t))}</td><td>${esc(t?.pid || '-')}</td><td>${esc(t?.exitCode ?? '-')}</td><td>${sessionCell(thread)}</td><td>${esc(t?.files?.lastMessage?.exists ? '有' : '-')}</td><td>${taskActionCell(id, t)}</td></tr>`;
|
|
318
|
+
}
|
|
319
|
+
function renderTasks() {
|
|
320
|
+
const s = currentState;
|
|
321
|
+
const rows = [taskRow('planner','planner',s.planner)];
|
|
322
|
+
if (Array.isArray(s.batches) && s.batches.length) {
|
|
323
|
+
for (const b of s.batches) {
|
|
324
|
+
const done = (b.tasks || []).filter(t => t.status === 'completed').length;
|
|
325
|
+
rows.push(`<tr class="batch-row"><td colspan="9">${esc(b.name || b.id)} ${pill(b.status)} <span class="muted">${esc(b.id)}|最大并发 ${esc(b.maxParallel || '-')}|${done}/${(b.tasks || []).length}</span></td></tr>`);
|
|
326
|
+
for (const t of b.tasks || []) rows.push(taskRow(t.id, t.name, t));
|
|
327
|
+
}
|
|
328
|
+
} else rows.push(...(s.tasks||[]).map(t => taskRow(t.id, t.name, t)));
|
|
329
|
+
rows.push(taskRow('judge','最终验收',s.judge));
|
|
330
|
+
document.getElementById('tasks').innerHTML = `<table><tr><th>任务</th><th>状态</th><th>发起时间</th><th>用时</th><th>进程号</th><th>退出码</th><th>Codex 会话ID</th><th>最终回复</th><th>操作</th></tr>${rows.join('')}</table>`;
|
|
331
|
+
}
|
|
332
|
+
async function selectTask(id) {
|
|
333
|
+
selectedTask = id;
|
|
334
|
+
document.getElementById('fileTitle').textContent = `${selectedRun} / ${selectedTask}`;
|
|
335
|
+
renderTasks();
|
|
336
|
+
if (selectedFileName) await loadFile(selectedFileName);
|
|
337
|
+
else document.getElementById('fileContent').textContent = '';
|
|
338
|
+
}
|
|
339
|
+
async function loadFile(name, { preserveScroll = false } = {}) {
|
|
340
|
+
if (!selectedRun || !selectedTask) return;
|
|
341
|
+
selectedFileName = name;
|
|
342
|
+
const pre = document.getElementById('fileContent');
|
|
343
|
+
const previousScrollTop = pre.scrollTop;
|
|
344
|
+
const text = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=${encodeURIComponent(name)}`);
|
|
345
|
+
pre.textContent = text;
|
|
346
|
+
if (preserveScroll) pre.scrollTop = previousScrollTop;
|
|
347
|
+
else pre.scrollTop = 0;
|
|
348
|
+
if (name === 'events.pretty') await renderExecutionSummary();
|
|
349
|
+
else hideExecutionSummary();
|
|
350
|
+
updateCopyLastMessageButton();
|
|
351
|
+
}
|
|
352
|
+
function clearFileView() {
|
|
353
|
+
document.getElementById('fileTitle').textContent = '点击任务后选择文件';
|
|
354
|
+
document.getElementById('fileContent').textContent = '';
|
|
355
|
+
hideExecutionSummary();
|
|
356
|
+
updateCopyLastMessageButton();
|
|
357
|
+
}
|
|
358
|
+
function updateCopyLastMessageButton() {
|
|
359
|
+
const button = document.getElementById('copyLastMessageBtn');
|
|
360
|
+
if (!button) return;
|
|
361
|
+
button.classList.toggle('hidden', selectedFileName !== 'last_message.md');
|
|
362
|
+
button.textContent = '⧉';
|
|
363
|
+
}
|
|
364
|
+
async function copyFileContent(event) {
|
|
365
|
+
event.stopPropagation();
|
|
366
|
+
const text = document.getElementById('fileContent').textContent || '';
|
|
367
|
+
if (!text) return;
|
|
368
|
+
try {
|
|
369
|
+
await navigator.clipboard.writeText(text);
|
|
370
|
+
event.currentTarget.textContent = '✓';
|
|
371
|
+
setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
|
|
372
|
+
} catch {
|
|
373
|
+
prompt('复制最终回复内容', text);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function hideExecutionSummary() {
|
|
377
|
+
const el = document.getElementById('executionSummary');
|
|
378
|
+
el.classList.add('hidden');
|
|
379
|
+
el.innerHTML = '';
|
|
380
|
+
}
|
|
381
|
+
async function renderExecutionSummary() {
|
|
382
|
+
const el = document.getElementById('executionSummary');
|
|
383
|
+
let raw = '';
|
|
384
|
+
try { raw = await api(`/api/runs/${selectedRun}/tasks/${selectedTask}/file?name=events.jsonl`); }
|
|
385
|
+
catch { raw = ''; }
|
|
386
|
+
const summary = summarizeEventsJsonl(raw);
|
|
387
|
+
el.classList.remove('hidden');
|
|
388
|
+
el.innerHTML = `<span>事件 ${summary.events}</span><span>命令开始 ${summary.commandStarted}</span><span>命令完成 ${summary.commandCompleted}</span><span>MCP ${summary.mcpCalls}</span><span>文件变更 ${summary.fileChanges}</span><span>模型回复 ${summary.agentMessages}</span><span>推理 ${summary.reasoning}</span><span>命令类型 ${esc(summary.commandKindsText)}</span><button class="secondary copy-btn" onclick="scrollFileToBottom()">跳到末尾</button>`;
|
|
389
|
+
}
|
|
390
|
+
function summarizeEventsJsonl(raw) {
|
|
391
|
+
const summary = { events: 0, commandStarted: 0, commandCompleted: 0, mcpCalls: 0, fileChanges: 0, agentMessages: 0, reasoning: 0, commandKinds: new Map(), commandKindsText: '-' };
|
|
392
|
+
for (const line of String(raw || '').split(/\r?\n/).filter(Boolean)) {
|
|
393
|
+
let event;
|
|
394
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
395
|
+
summary.events++;
|
|
396
|
+
const item = event.item || {};
|
|
397
|
+
const type = item.type || '';
|
|
398
|
+
if (type === 'command_execution') {
|
|
399
|
+
if (event.type === 'item.started') summary.commandStarted++;
|
|
400
|
+
if (event.type === 'item.completed') summary.commandCompleted++;
|
|
401
|
+
const kind = commandKind(item.command || 'unknown');
|
|
402
|
+
summary.commandKinds.set(kind, (summary.commandKinds.get(kind) || 0) + 1);
|
|
403
|
+
}
|
|
404
|
+
if (type === 'mcp_tool_call' || type === 'mcpToolCall') summary.mcpCalls++;
|
|
405
|
+
if (type === 'file_change' || type === 'fileChange') summary.fileChanges++;
|
|
406
|
+
if (type === 'agent_message' || type === 'agentMessage') summary.agentMessages++;
|
|
407
|
+
if (type === 'reasoning') summary.reasoning++;
|
|
408
|
+
}
|
|
409
|
+
const kinds = [...summary.commandKinds.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
410
|
+
summary.commandKindsText = kinds.length ? kinds.map(([kind, count]) => `${kind}:${count}`).join(' / ') : '-';
|
|
411
|
+
return summary;
|
|
412
|
+
}
|
|
413
|
+
function commandKind(command) {
|
|
414
|
+
const text = String(command || '').trim();
|
|
415
|
+
if (text.includes('apply_patch')) return 'apply_patch';
|
|
416
|
+
if (text.includes('npm ')) return 'npm';
|
|
417
|
+
if (text.includes('git ')) return 'git';
|
|
418
|
+
if (text.includes('rg ')) return 'rg';
|
|
419
|
+
if (text.includes('python')) return 'python';
|
|
420
|
+
if (text.includes('node ')) return 'node';
|
|
421
|
+
if (text.includes('curl ')) return 'curl';
|
|
422
|
+
const match = text.match(/(?:^|\s)([\w./-]+)(?:\s|$)/);
|
|
423
|
+
return match ? match[1].split('/').pop() : 'unknown';
|
|
424
|
+
}
|
|
425
|
+
function scrollFileToBottom() {
|
|
426
|
+
const pre = document.getElementById('fileContent');
|
|
427
|
+
pre.scrollTop = pre.scrollHeight;
|
|
428
|
+
}
|
|
429
|
+
async function copySessionId(event, thread) {
|
|
430
|
+
event.stopPropagation();
|
|
431
|
+
try {
|
|
432
|
+
await navigator.clipboard.writeText(thread);
|
|
433
|
+
event.currentTarget.textContent = '✓';
|
|
434
|
+
setTimeout(() => { event.currentTarget.textContent = '⧉'; }, 900);
|
|
435
|
+
} catch {
|
|
436
|
+
prompt('复制 Codex 会话ID', thread);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function planRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/plan`, {method:'POST'}); await refreshSelected(); }); }
|
|
440
|
+
async function dispatchRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/dispatch`, {method:'POST'}); await refreshSelected(); }); }
|
|
441
|
+
async function judgeRun() { if (selectedRun) await runAction(async () => { await api(`/api/runs/${selectedRun}/judge`, {method:'POST'}); await refreshSelected(); }); }
|
|
442
|
+
async function runAction(fn) {
|
|
443
|
+
try { await fn(); }
|
|
444
|
+
catch (error) { alert(error.message || String(error)); }
|
|
445
|
+
}
|
|
446
|
+
async function stopSelectedRun() {
|
|
447
|
+
if (!selectedRun) return;
|
|
448
|
+
const ok = confirm('确认停止当前任务批次?\n\n停止会终止仍在运行的 codex exec 子进程,并冻结后续调度。');
|
|
449
|
+
if (!ok) return;
|
|
450
|
+
await api(`/api/runs/${selectedRun}/stop`, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
body: JSON.stringify({ reason: 'stopped from dashboard' })
|
|
453
|
+
});
|
|
454
|
+
await refreshSelected();
|
|
455
|
+
}
|
|
456
|
+
async function archiveSelectedRun() {
|
|
457
|
+
if (!selectedRun) return;
|
|
458
|
+
const ok = confirm('确认归档当前任务批次?\n\n归档后会从默认任务批次列表隐藏。若仍有任务运行,请先停止。');
|
|
459
|
+
if (!ok) return;
|
|
460
|
+
await api(`/api/runs/${selectedRun}/archive`, {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
body: JSON.stringify({ reason: 'archived from dashboard' })
|
|
463
|
+
});
|
|
464
|
+
selectedRun = null; selectedTask = null; selectedFileName = null; currentState = null;
|
|
465
|
+
document.getElementById('selected').textContent = '未选择任务批次';
|
|
466
|
+
document.getElementById('taskDescription').textContent = '未选择任务批次';
|
|
467
|
+
document.getElementById('tasks').innerHTML = '';
|
|
468
|
+
clearFileView();
|
|
469
|
+
updateAutoRefreshHint();
|
|
470
|
+
await refreshRuns();
|
|
471
|
+
}
|
|
472
|
+
async function markTaskCompleted(event, taskId) {
|
|
473
|
+
event.stopPropagation();
|
|
474
|
+
if (!selectedRun || taskId === 'planner' || taskId === 'judge') return;
|
|
475
|
+
const task = (currentState?.tasks || []).find(t => t.id === taskId);
|
|
476
|
+
if (task?.status === 'running') { alert('任务仍在执行中,不能手动标记成功。'); return; }
|
|
477
|
+
const ok = confirm(`确认将任务 ${taskId} 手动标记为成功?\n\n请仅在你已经通过 CLI / Codex 会话确认该任务实际完成时使用。\n原始状态会保留在页面和 manual_completion.json 中。`);
|
|
478
|
+
if (!ok) return;
|
|
479
|
+
selectedTask = taskId;
|
|
480
|
+
await api(`/api/runs/${selectedRun}/tasks/${taskId}/mark-completed`, {
|
|
481
|
+
method: 'POST',
|
|
482
|
+
body: JSON.stringify({ reason: 'manual success confirmed from dashboard' })
|
|
483
|
+
});
|
|
484
|
+
await refreshSelected();
|
|
485
|
+
await loadFile('manual_completion.json');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
loadHealth().then(refreshRuns);
|
|
489
|
+
setInterval(() => { if (selectedRun) refreshSelected({auto:true}).catch(console.error); else refreshRuns().catch(console.error); }, AUTO_REFRESH_MS);
|
|
490
|
+
</script>
|
|
491
|
+
</body>
|
|
492
|
+
</html>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { CODEX_BIN } from './utils.js';
|
|
4
|
+
|
|
5
|
+
export class CodexAppServerClient {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.proc = null;
|
|
8
|
+
this.nextId = 1;
|
|
9
|
+
this.pending = new Map();
|
|
10
|
+
this.initialized = false;
|
|
11
|
+
this.stderrTail = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
start() {
|
|
15
|
+
if (this.proc) return;
|
|
16
|
+
this.proc = spawn(CODEX_BIN, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
17
|
+
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
18
|
+
rl.on('line', line => this.#handleLine(line));
|
|
19
|
+
this.proc.stderr.on('data', d => this.#pushStderr(String(d)));
|
|
20
|
+
this.proc.on('exit', code => {
|
|
21
|
+
for (const { reject } of this.pending.values()) reject(new Error(`app-server exited: ${code}`));
|
|
22
|
+
this.pending.clear();
|
|
23
|
+
this.proc = null;
|
|
24
|
+
this.initialized = false;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#pushStderr(s) {
|
|
29
|
+
for (const line of s.split(/\r?\n/).filter(Boolean)) this.stderrTail.push(line);
|
|
30
|
+
this.stderrTail = this.stderrTail.slice(-50);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#handleLine(line) {
|
|
34
|
+
let msg;
|
|
35
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(msg, 'id')) return;
|
|
37
|
+
const p = this.pending.get(msg.id);
|
|
38
|
+
if (!p) return;
|
|
39
|
+
this.pending.delete(msg.id);
|
|
40
|
+
if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
|
|
41
|
+
else p.resolve(msg.result);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async request(method, params = null, timeoutMs = 15000) {
|
|
45
|
+
this.start();
|
|
46
|
+
const id = this.nextId++;
|
|
47
|
+
const msg = { id, method };
|
|
48
|
+
if (params !== null) msg.params = params;
|
|
49
|
+
return await new Promise((resolve, reject) => {
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
this.pending.delete(id);
|
|
52
|
+
reject(new Error(`app-server request timeout: ${method}`));
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
this.pending.set(id, { resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); } });
|
|
55
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async ensureInitialized() {
|
|
60
|
+
if (this.initialized) return;
|
|
61
|
+
await this.request('initialize', {
|
|
62
|
+
clientInfo: { name: 'codex-orchestrator-kanban', version: '0.1.0' },
|
|
63
|
+
capabilities: { experimentalApi: true }
|
|
64
|
+
});
|
|
65
|
+
this.initialized = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async listThreads({ cwd, limit = 100, searchTerm = null } = {}) {
|
|
69
|
+
await this.ensureInitialized();
|
|
70
|
+
return await this.request('thread/list', {
|
|
71
|
+
cwd: cwd || null,
|
|
72
|
+
sourceKinds: ['exec', 'appServer'],
|
|
73
|
+
limit,
|
|
74
|
+
searchTerm,
|
|
75
|
+
sortDirection: 'desc',
|
|
76
|
+
sortKey: 'created_at'
|
|
77
|
+
}, 20000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stop() {
|
|
81
|
+
if (!this.proc) return;
|
|
82
|
+
this.proc.kill('TERM');
|
|
83
|
+
this.proc = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function matchThreadToMarkers(thread, runId, taskId) {
|
|
88
|
+
const preview = thread?.preview || '';
|
|
89
|
+
return preview.includes(`ORCHESTRATOR_RUN_ID: ${runId}`) && preview.includes(`ORCHESTRATOR_TASK_ID: ${taskId}`);
|
|
90
|
+
}
|