@sebastianandreasson/pi-autonomous-agents 0.5.2 → 0.7.0
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/README.md +28 -9
- package/SETUP.md +6 -0
- package/docs/PI_SUPERVISOR.md +16 -65
- package/package.json +6 -3
- package/pi.config.json +1 -2
- package/src/cli.mjs +1 -1
- package/src/index.mjs +3 -0
- package/src/pi-client.mjs +68 -119
- package/src/pi-config.mjs +3 -3
- package/src/pi-sdk-turn.mjs +654 -0
- package/src/pi-supervisor.mjs +73 -0
- package/src/pi-telemetry.mjs +4 -0
- package/src/pi-visualizer-server.mjs +522 -0
- package/src/pi-visualizer-shared.mjs +219 -0
- package/src/pi-visualizer.mjs +16 -0
- package/templates/pi.config.example.json +1 -2
- package/src/pi-rpc-adapter.mjs +0 -668
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { readTelemetry } from './pi-telemetry.mjs'
|
|
6
|
+
import { readJsonFile } from './pi-repo.mjs'
|
|
7
|
+
import { deriveFlowSnapshot, deriveStageGraph, formatActiveLabel } from './pi-visualizer-shared.mjs'
|
|
8
|
+
|
|
9
|
+
export function readVisualizerHost() {
|
|
10
|
+
return String(process.env.PI_VISUALIZER_HOST ?? '127.0.0.1').trim() || '127.0.0.1'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readVisualizerPort() {
|
|
14
|
+
const raw = Number.parseInt(String(process.env.PI_VISUALIZER_PORT ?? '4317'), 10)
|
|
15
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 4317
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readOptionalText(filePath, maxLength = 6000) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(filePath, 'utf8')
|
|
21
|
+
const text = raw.trim()
|
|
22
|
+
if (text.length <= maxLength) {
|
|
23
|
+
return text
|
|
24
|
+
}
|
|
25
|
+
return `${text.slice(0, maxLength - 15)}\n... [truncated]`
|
|
26
|
+
} catch {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getRunDir(config, runId) {
|
|
32
|
+
return path.join(config.piRuntimeDir, 'runs', runId)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getRunScopedConfig(config, runId) {
|
|
36
|
+
const runDir = getRunDir(config, runId)
|
|
37
|
+
return {
|
|
38
|
+
...config,
|
|
39
|
+
runId,
|
|
40
|
+
telemetryJsonl: path.join(runDir, 'pi_telemetry.jsonl'),
|
|
41
|
+
telemetryCsv: path.join(runDir, 'pi_telemetry.csv'),
|
|
42
|
+
stateFile: path.join(runDir, 'state.json'),
|
|
43
|
+
lastIterationSummaryFile: path.join(runDir, 'last-iteration.json'),
|
|
44
|
+
lastAgentOutputFile: path.join(runDir, 'last-output.txt'),
|
|
45
|
+
logFile: path.join(runDir, 'pi.log'),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function listRuns(config, activeRun) {
|
|
50
|
+
const runsDir = path.join(config.piRuntimeDir, 'runs')
|
|
51
|
+
let entries = []
|
|
52
|
+
try {
|
|
53
|
+
entries = await fs.readdir(runsDir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const runs = await Promise.all(entries
|
|
59
|
+
.filter((entry) => entry.isDirectory())
|
|
60
|
+
.map(async (entry) => {
|
|
61
|
+
const runId = entry.name
|
|
62
|
+
const scoped = getRunScopedConfig(config, runId)
|
|
63
|
+
const [state, summary, stat] = await Promise.all([
|
|
64
|
+
readJsonFile(scoped.stateFile, null),
|
|
65
|
+
readJsonFile(scoped.lastIterationSummaryFile, null),
|
|
66
|
+
fs.stat(getRunDir(config, runId)).catch(() => null),
|
|
67
|
+
])
|
|
68
|
+
return {
|
|
69
|
+
runId,
|
|
70
|
+
active: String(activeRun?.runId ?? '') === runId,
|
|
71
|
+
mtimeMs: Number(stat?.mtimeMs ?? 0),
|
|
72
|
+
status: String(activeRun?.runId ?? '') === runId
|
|
73
|
+
? String(activeRun?.status ?? '')
|
|
74
|
+
: String(state?.lastStatus ?? state?.inProgress?.status ?? ''),
|
|
75
|
+
iteration: Number(state?.iteration ?? summary?.iteration ?? 0),
|
|
76
|
+
phase: String(state?.lastPhase ?? summary?.phase ?? activeRun?.phase ?? ''),
|
|
77
|
+
task: String(summary?.task ?? activeRun?.task ?? ''),
|
|
78
|
+
runStartedAt: String(state?.runStartedAt ?? activeRun?.startedAt ?? ''),
|
|
79
|
+
lastRunAt: String(state?.lastRunAt ?? ''),
|
|
80
|
+
}
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
return runs.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveSelectedRunId(queryRunId, activeRun, runs) {
|
|
87
|
+
const requested = String(queryRunId ?? '').trim()
|
|
88
|
+
if (requested !== '' && runs.some((run) => run.runId === requested)) {
|
|
89
|
+
return requested
|
|
90
|
+
}
|
|
91
|
+
const activeRunId = String(activeRun?.runId ?? '').trim()
|
|
92
|
+
if (activeRunId !== '' && runs.some((run) => run.runId === activeRunId)) {
|
|
93
|
+
return activeRunId
|
|
94
|
+
}
|
|
95
|
+
return runs[0]?.runId ?? ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function buildSnapshot(config, queryRunId = '') {
|
|
99
|
+
const activeRun = await readJsonFile(config.activeRunFile, null)
|
|
100
|
+
const runs = await listRuns(config, activeRun)
|
|
101
|
+
const selectedRunId = resolveSelectedRunId(queryRunId, activeRun, runs)
|
|
102
|
+
const selectedConfig = selectedRunId !== '' ? getRunScopedConfig(config, selectedRunId) : config
|
|
103
|
+
|
|
104
|
+
const [state, summary, telemetry, currentOutput] = await Promise.all([
|
|
105
|
+
readJsonFile(selectedConfig.stateFile, null),
|
|
106
|
+
readJsonFile(selectedConfig.lastIterationSummaryFile, null),
|
|
107
|
+
readTelemetry(selectedConfig),
|
|
108
|
+
readOptionalText(selectedConfig.lastAgentOutputFile, 5000),
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
const recentTelemetry = telemetry.slice(-160).map((event, index) => ({
|
|
112
|
+
...event,
|
|
113
|
+
_vizId: `telemetry-${index}`,
|
|
114
|
+
}))
|
|
115
|
+
const flow = deriveFlowSnapshot({
|
|
116
|
+
activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
|
|
117
|
+
summary,
|
|
118
|
+
telemetry,
|
|
119
|
+
})
|
|
120
|
+
const graph = deriveStageGraph({
|
|
121
|
+
activeRun: selectedRunId !== '' && String(activeRun?.runId ?? '') === selectedRunId ? activeRun : state?.inProgress ?? null,
|
|
122
|
+
summary,
|
|
123
|
+
telemetry,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
now: new Date().toISOString(),
|
|
128
|
+
config: {
|
|
129
|
+
cwd: config.cwd,
|
|
130
|
+
transport: config.transport,
|
|
131
|
+
telemetryJsonl: selectedConfig.telemetryJsonl,
|
|
132
|
+
activeRunFile: config.activeRunFile,
|
|
133
|
+
stateFile: selectedConfig.stateFile,
|
|
134
|
+
lastIterationSummaryFile: selectedConfig.lastIterationSummaryFile,
|
|
135
|
+
selectedRunId,
|
|
136
|
+
},
|
|
137
|
+
runs,
|
|
138
|
+
activeRun,
|
|
139
|
+
state,
|
|
140
|
+
summary,
|
|
141
|
+
flow: {
|
|
142
|
+
...flow,
|
|
143
|
+
activeLabel: formatActiveLabel(activeRun, flow),
|
|
144
|
+
},
|
|
145
|
+
graph,
|
|
146
|
+
lastOutput: currentOutput,
|
|
147
|
+
recentTelemetry,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderHtml() {
|
|
152
|
+
return `<!doctype html>
|
|
153
|
+
<html lang="en">
|
|
154
|
+
<head>
|
|
155
|
+
<meta charset="utf-8" />
|
|
156
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
157
|
+
<title>PI Harness Visualizer</title>
|
|
158
|
+
<style>
|
|
159
|
+
:root {
|
|
160
|
+
--bg: #0b1020;
|
|
161
|
+
--panel: #121a30;
|
|
162
|
+
--panel2: #17213d;
|
|
163
|
+
--text: #e6edf7;
|
|
164
|
+
--muted: #95a3bf;
|
|
165
|
+
--line: #263252;
|
|
166
|
+
--active: #6ee7ff;
|
|
167
|
+
--done: #53d18d;
|
|
168
|
+
--error: #ff6b81;
|
|
169
|
+
--skip: #f0b35a;
|
|
170
|
+
--pending: #4b5675;
|
|
171
|
+
}
|
|
172
|
+
* { box-sizing: border-box; }
|
|
173
|
+
body {
|
|
174
|
+
margin: 0; font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
175
|
+
background: linear-gradient(180deg, #08101d, #0b1020 180px); color: var(--text);
|
|
176
|
+
}
|
|
177
|
+
.wrap { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
|
178
|
+
.header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 20px; }
|
|
179
|
+
.title { font-size: 28px; font-weight: 700; }
|
|
180
|
+
.subtitle { color: var(--muted); margin-top: 4px; }
|
|
181
|
+
.toolbar { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
|
182
|
+
.badge, select {
|
|
183
|
+
display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 999px;
|
|
184
|
+
border: 1px solid var(--line); background: rgba(255,255,255,0.03); color: var(--text);
|
|
185
|
+
font: inherit;
|
|
186
|
+
}
|
|
187
|
+
select { min-width: 260px; }
|
|
188
|
+
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--pending); }
|
|
189
|
+
.dot.active { background: var(--active); box-shadow: 0 0 18px rgba(110,231,255,.6); }
|
|
190
|
+
.grid { display: grid; gap: 16px; }
|
|
191
|
+
.grid.top { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; }
|
|
192
|
+
.grid.main { grid-template-columns: 1.25fr .95fr; }
|
|
193
|
+
.card {
|
|
194
|
+
background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(255,255,255,.01));
|
|
195
|
+
border: 1px solid var(--line); border-radius: 16px; padding: 16px;
|
|
196
|
+
box-shadow: 0 12px 40px rgba(0,0,0,.18);
|
|
197
|
+
}
|
|
198
|
+
.label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
199
|
+
.value { margin-top: 8px; font-size: 22px; font-weight: 700; }
|
|
200
|
+
.value.small { font-size: 16px; }
|
|
201
|
+
.flow { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
|
202
|
+
.step, .graph-node {
|
|
203
|
+
border: 1px solid var(--line); border-radius: 14px; padding: 12px; background: var(--panel);
|
|
204
|
+
min-height: 96px; position: relative; overflow: hidden;
|
|
205
|
+
}
|
|
206
|
+
.step::before, .graph-node::before {
|
|
207
|
+
content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--pending);
|
|
208
|
+
}
|
|
209
|
+
.step.active::before, .graph-node.active::before { background: var(--active); }
|
|
210
|
+
.step.done::before, .graph-node.done::before { background: var(--done); }
|
|
211
|
+
.step.error::before, .graph-node.error::before { background: var(--error); }
|
|
212
|
+
.step.skipped::before, .graph-node.skipped::before { background: var(--skip); }
|
|
213
|
+
.step-name { font-weight: 700; margin-bottom: 6px; }
|
|
214
|
+
.step-status { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
|
215
|
+
.step-meta { margin-top: 8px; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
|
|
216
|
+
.graph { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-top:12px; }
|
|
217
|
+
.graph-node { min-height: 120px; width: 100%; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
|
|
218
|
+
.graph-arrow { color: var(--muted); text-align: center; align-self: center; }
|
|
219
|
+
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 6px 10px; margin-top: 12px; }
|
|
220
|
+
.kv div:nth-child(odd) { color: var(--muted); }
|
|
221
|
+
pre {
|
|
222
|
+
margin: 0; white-space: pre-wrap; word-break: break-word; background: #0a1325;
|
|
223
|
+
border: 1px solid var(--line); border-radius: 12px; padding: 12px; max-height: 320px; overflow: auto;
|
|
224
|
+
}
|
|
225
|
+
table { width: 100%; border-collapse: collapse; }
|
|
226
|
+
th, td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
|
|
227
|
+
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
|
228
|
+
td { font-size: 13px; }
|
|
229
|
+
.status-pill {
|
|
230
|
+
display: inline-block; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700;
|
|
231
|
+
border: 1px solid var(--line); background: var(--panel2);
|
|
232
|
+
}
|
|
233
|
+
.status-pill.done { color: var(--done); }
|
|
234
|
+
.status-pill.error { color: var(--error); }
|
|
235
|
+
.status-pill.skipped { color: var(--skip); }
|
|
236
|
+
.status-pill.active { color: var(--active); }
|
|
237
|
+
.muted { color: var(--muted); }
|
|
238
|
+
@media (max-width: 1100px) { .grid.top, .grid.main, .flow { grid-template-columns: 1fr; } }
|
|
239
|
+
</style>
|
|
240
|
+
</head>
|
|
241
|
+
<body>
|
|
242
|
+
<div class="wrap">
|
|
243
|
+
<div class="header">
|
|
244
|
+
<div>
|
|
245
|
+
<div class="title">PI Harness Visualizer</div>
|
|
246
|
+
<div class="subtitle" id="cwd"></div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="toolbar">
|
|
249
|
+
<select id="run-select"></select>
|
|
250
|
+
<div class="badge"><span class="dot active"></span><span id="last-refresh">Loading...</span></div>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div class="grid top">
|
|
255
|
+
<div class="card"><div class="label">Current activity</div><div class="value" id="active-label">—</div></div>
|
|
256
|
+
<div class="card"><div class="label">Iteration</div><div class="value" id="iteration">—</div></div>
|
|
257
|
+
<div class="card"><div class="label">Phase</div><div class="value small" id="phase">—</div></div>
|
|
258
|
+
<div class="card"><div class="label">Task</div><div class="value small" id="task">—</div></div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div class="card" style="margin-bottom: 16px;">
|
|
262
|
+
<div class="label">Orchestration flow</div>
|
|
263
|
+
<div class="flow" id="flow"></div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div class="card" style="margin-bottom: 16px;">
|
|
267
|
+
<div class="label">Iteration stage graph</div>
|
|
268
|
+
<div class="graph" id="graph"></div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="grid main">
|
|
272
|
+
<div class="card">
|
|
273
|
+
<div class="label">Recent telemetry timeline</div>
|
|
274
|
+
<div style="margin-top: 12px; overflow: auto; max-height: 620px;">
|
|
275
|
+
<table>
|
|
276
|
+
<thead><tr><th>Time</th><th>Iteration</th><th>Kind</th><th>Status</th><th>Notes</th></tr></thead>
|
|
277
|
+
<tbody id="timeline"></tbody>
|
|
278
|
+
</table>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div class="grid">
|
|
283
|
+
<div class="card"><div class="label">Run state</div><div class="kv" id="run-state"></div></div>
|
|
284
|
+
<div class="card"><div class="label">Selected event</div><pre id="selected-event">Click graph node or timeline row.</pre></div>
|
|
285
|
+
<div class="card"><div class="label">Last iteration summary</div><pre id="summary">—</pre></div>
|
|
286
|
+
<div class="card"><div class="label">Last agent output</div><pre id="output">—</pre></div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<script>
|
|
292
|
+
function esc(value) {
|
|
293
|
+
return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
294
|
+
}
|
|
295
|
+
function pillClass(status) {
|
|
296
|
+
if (status === 'done') return 'status-pill done'
|
|
297
|
+
if (status === 'error') return 'status-pill error'
|
|
298
|
+
if (status === 'skipped') return 'status-pill skipped'
|
|
299
|
+
if (status === 'active') return 'status-pill active'
|
|
300
|
+
return 'status-pill'
|
|
301
|
+
}
|
|
302
|
+
function eventStatus(status) {
|
|
303
|
+
if (status === 'success' || status === 'passed' || status === 'complete') return 'done'
|
|
304
|
+
if (status === 'skipped' || status === 'not_run' || status === 'not_needed') return 'skipped'
|
|
305
|
+
if (status === 'failed' || status === 'timed_out' || status === 'stalled' || status === 'blocked') return 'error'
|
|
306
|
+
return ''
|
|
307
|
+
}
|
|
308
|
+
function selectedRunId() {
|
|
309
|
+
return new URLSearchParams(location.search).get('runId') || ''
|
|
310
|
+
}
|
|
311
|
+
function updateRunQuery(runId) {
|
|
312
|
+
const url = new URL(location.href)
|
|
313
|
+
if (runId) url.searchParams.set('runId', runId)
|
|
314
|
+
else url.searchParams.delete('runId')
|
|
315
|
+
history.replaceState(null, '', url)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let latestSnapshot = null
|
|
319
|
+
let selectedEventId = ''
|
|
320
|
+
let eventSource = null
|
|
321
|
+
|
|
322
|
+
function findEventById(snapshot, eventId) {
|
|
323
|
+
if (!snapshot || !eventId) return null
|
|
324
|
+
return snapshot.graph?.nodes?.find((node) => node.id === eventId)?.event
|
|
325
|
+
|| snapshot.recentTelemetry?.find((event) => event._vizId === eventId)
|
|
326
|
+
|| null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderSelectedEvent() {
|
|
330
|
+
const event = findEventById(latestSnapshot, selectedEventId)
|
|
331
|
+
document.getElementById('selected-event').textContent = event
|
|
332
|
+
? JSON.stringify(event, null, 2)
|
|
333
|
+
: 'Click graph node or timeline row.'
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function bindSelectableEvents() {
|
|
337
|
+
document.querySelectorAll('[data-event-id]').forEach((element) => {
|
|
338
|
+
element.addEventListener('click', () => {
|
|
339
|
+
selectedEventId = element.getAttribute('data-event-id') || ''
|
|
340
|
+
renderSelectedEvent()
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function renderSnapshot(data) {
|
|
346
|
+
latestSnapshot = data
|
|
347
|
+
document.getElementById('cwd').textContent = data.config.cwd
|
|
348
|
+
document.getElementById('last-refresh').textContent = 'Updated ' + new Date(data.now).toLocaleTimeString()
|
|
349
|
+
document.getElementById('active-label').textContent = data.flow.activeLabel || 'Idle'
|
|
350
|
+
document.getElementById('iteration').textContent = data.flow.iteration || '—'
|
|
351
|
+
document.getElementById('phase').textContent = data.activeRun?.phase || data.summary?.phase || '—'
|
|
352
|
+
document.getElementById('task').textContent = data.activeRun?.task || data.summary?.task || '—'
|
|
353
|
+
|
|
354
|
+
const select = document.getElementById('run-select')
|
|
355
|
+
const selected = data.config.selectedRunId || ''
|
|
356
|
+
select.innerHTML = data.runs.map((run) => {
|
|
357
|
+
const suffix = [run.status, run.phase].filter(Boolean).join(' · ')
|
|
358
|
+
return '<option value="' + esc(run.runId) + '" ' + (selected === run.runId ? 'selected' : '') + '>' +
|
|
359
|
+
esc(run.runId.slice(0, 8) + (suffix ? ' — ' + suffix : '')) + '</option>'
|
|
360
|
+
}).join('')
|
|
361
|
+
if (!select.dataset.bound) {
|
|
362
|
+
select.addEventListener('change', (event) => {
|
|
363
|
+
updateRunQuery(event.target.value)
|
|
364
|
+
connectStream()
|
|
365
|
+
})
|
|
366
|
+
select.dataset.bound = '1'
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const flowEl = document.getElementById('flow')
|
|
370
|
+
flowEl.innerHTML = data.flow.steps.map((step) => {
|
|
371
|
+
const latest = step.latestEvent
|
|
372
|
+
const meta = latest ? [latest.kind, latest.status, latest.terminalReason].filter(Boolean).join('\n') : 'waiting'
|
|
373
|
+
return '<div class="step ' + esc(step.status) + '">' +
|
|
374
|
+
'<div class="step-name">' + esc(step.label) + '</div>' +
|
|
375
|
+
'<div class="step-status">' + esc(step.status) + '</div>' +
|
|
376
|
+
'<div class="step-meta">' + esc(meta) + '</div>' +
|
|
377
|
+
'</div>'
|
|
378
|
+
}).join('')
|
|
379
|
+
|
|
380
|
+
const graphEl = document.getElementById('graph')
|
|
381
|
+
graphEl.innerHTML = data.graph.nodes.length > 0
|
|
382
|
+
? data.graph.nodes.map((node) => {
|
|
383
|
+
const retry = node.retryCount > 0 ? 'retry #' + node.retryCount : ''
|
|
384
|
+
const meta = [node.kind, retry, node.role, node.terminalReason].filter(Boolean).join('\n')
|
|
385
|
+
return '<button type="button" class="graph-node ' + esc(node.status) + '" data-event-id="' + esc(node.id) + '">' +
|
|
386
|
+
'<div class="step-name">' + esc(node.label) + '</div>' +
|
|
387
|
+
'<div class="step-status">' + esc(node.status) + '</div>' +
|
|
388
|
+
'<div class="step-meta">' + esc(meta) + '\n' + esc(node.notes || '') + '</div>' +
|
|
389
|
+
'</button>'
|
|
390
|
+
}).join('')
|
|
391
|
+
: '<div class="muted">No iteration graph yet.</div>'
|
|
392
|
+
|
|
393
|
+
const runState = [
|
|
394
|
+
['runId', data.activeRun?.runId || data.state?.runId || data.config.selectedRunId || '—'],
|
|
395
|
+
['status', data.activeRun?.status || data.state?.inProgress?.status || '—'],
|
|
396
|
+
['activeKind', data.activeRun?.activeKind || '—'],
|
|
397
|
+
['activeRole', data.activeRun?.activeRole || '—'],
|
|
398
|
+
['reason', data.activeRun?.activeReason || '—'],
|
|
399
|
+
['transport', data.config.transport || '—'],
|
|
400
|
+
['lastStatus', data.activeRun?.lastStatus || data.state?.lastStatus || '—'],
|
|
401
|
+
['lastCompleted', data.activeRun?.lastCompletedIteration || '—'],
|
|
402
|
+
]
|
|
403
|
+
document.getElementById('run-state').innerHTML = runState.map(([k, v]) => '<div>' + esc(k) + '</div><div>' + esc(v) + '</div>').join('')
|
|
404
|
+
document.getElementById('summary').textContent = data.summary ? JSON.stringify(data.summary, null, 2) : 'No iteration summary yet.'
|
|
405
|
+
document.getElementById('output').textContent = data.lastOutput || 'No agent output yet.'
|
|
406
|
+
|
|
407
|
+
const timelineEvents = [...data.recentTelemetry].reverse()
|
|
408
|
+
const timeline = timelineEvents.map((event) => {
|
|
409
|
+
const status = eventStatus(event.status)
|
|
410
|
+
return '<tr data-event-id="' + esc(event._vizId) + '" style="cursor:pointer;">' +
|
|
411
|
+
'<td>' + esc(new Date(event.timestamp).toLocaleTimeString()) + '</td>' +
|
|
412
|
+
'<td>' + esc(event.iteration) + '</td>' +
|
|
413
|
+
'<td>' + esc(event.kind) + '</td>' +
|
|
414
|
+
'<td><span class="' + pillClass(status) + '">' + esc(event.status) + '</span></td>' +
|
|
415
|
+
'<td class="muted">' + esc(event.notes || '') + '</td>' +
|
|
416
|
+
'</tr>'
|
|
417
|
+
}).join('')
|
|
418
|
+
document.getElementById('timeline').innerHTML = timeline || '<tr><td colspan="5" class="muted">No telemetry yet.</td></tr>'
|
|
419
|
+
|
|
420
|
+
bindSelectableEvents()
|
|
421
|
+
renderSelectedEvent()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function connectStream() {
|
|
425
|
+
if (eventSource) {
|
|
426
|
+
eventSource.close()
|
|
427
|
+
}
|
|
428
|
+
const runId = selectedRunId()
|
|
429
|
+
const qs = runId ? '?runId=' + encodeURIComponent(runId) : ''
|
|
430
|
+
eventSource = new EventSource('/api/stream' + qs)
|
|
431
|
+
eventSource.onmessage = (event) => {
|
|
432
|
+
try {
|
|
433
|
+
renderSnapshot(JSON.parse(event.data))
|
|
434
|
+
} catch (error) {
|
|
435
|
+
document.getElementById('output').textContent = String(error)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
eventSource.onerror = () => {
|
|
439
|
+
document.getElementById('last-refresh').textContent = 'Reconnecting…'
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
connectStream()
|
|
444
|
+
</script>
|
|
445
|
+
</body>
|
|
446
|
+
</html>`
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function startVisualizerServer(config, overrides = {}) {
|
|
450
|
+
const host = String(overrides.host ?? readVisualizerHost()).trim() || '127.0.0.1'
|
|
451
|
+
const port = Number.isFinite(Number(overrides.port)) ? Number(overrides.port) : readVisualizerPort()
|
|
452
|
+
|
|
453
|
+
const server = http.createServer(async (req, res) => {
|
|
454
|
+
try {
|
|
455
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
456
|
+
if (url.pathname === '/api/stream') {
|
|
457
|
+
const runId = url.searchParams.get('runId') || ''
|
|
458
|
+
res.writeHead(200, {
|
|
459
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
460
|
+
'cache-control': 'no-store',
|
|
461
|
+
connection: 'keep-alive',
|
|
462
|
+
})
|
|
463
|
+
const send = async () => {
|
|
464
|
+
const snapshot = await buildSnapshot(config, runId)
|
|
465
|
+
res.write(`data: ${JSON.stringify(snapshot)}\n\n`)
|
|
466
|
+
}
|
|
467
|
+
await send()
|
|
468
|
+
const interval = setInterval(() => {
|
|
469
|
+
send().catch(() => {})
|
|
470
|
+
}, 1500)
|
|
471
|
+
req.on('close', () => {
|
|
472
|
+
clearInterval(interval)
|
|
473
|
+
res.end()
|
|
474
|
+
})
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (url.pathname === '/api/snapshot') {
|
|
478
|
+
const snapshot = await buildSnapshot(config, url.searchParams.get('runId') || '')
|
|
479
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
480
|
+
res.end(JSON.stringify(snapshot))
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
484
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
485
|
+
res.end(renderHtml())
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
489
|
+
res.end('Not found')
|
|
490
|
+
} catch (error) {
|
|
491
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
492
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }))
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
await new Promise((resolve, reject) => {
|
|
497
|
+
server.once('error', reject)
|
|
498
|
+
server.listen(port, host, () => {
|
|
499
|
+
server.off('error', reject)
|
|
500
|
+
resolve()
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
const url = `http://${host}:${port}`
|
|
505
|
+
return {
|
|
506
|
+
server,
|
|
507
|
+
host,
|
|
508
|
+
port,
|
|
509
|
+
url,
|
|
510
|
+
async close() {
|
|
511
|
+
await new Promise((resolve, reject) => {
|
|
512
|
+
server.close((error) => {
|
|
513
|
+
if (error) {
|
|
514
|
+
reject(error)
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
resolve()
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
}
|