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