@sebastianandreasson/pi-autonomous-agents 0.6.0 → 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 +6 -1
- package/SETUP.md +1 -0
- package/docs/PI_SUPERVISOR.md +4 -2
- package/package.json +2 -2
- package/src/index.mjs +1 -0
- package/src/pi-supervisor.mjs +15 -0
- package/src/pi-visualizer-server.mjs +522 -0
- package/src/pi-visualizer.mjs +3 -463
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ pi/
|
|
|
61
61
|
Typical scripts:
|
|
62
62
|
|
|
63
63
|
- `pi:once` / `pi:run` use default `sdk` transport
|
|
64
|
+
- `pi:run` also hosts web UI on `127.0.0.1:4317` by default
|
|
64
65
|
- `pi:mock` skips real agent execution
|
|
65
66
|
|
|
66
67
|
|
|
@@ -264,7 +265,11 @@ Useful files during a run:
|
|
|
264
265
|
|
|
265
266
|
`pi-harness report` summarizes recent telemetry and surfaces things like terminal reasons and large-file warnings.
|
|
266
267
|
|
|
267
|
-
`pi-harness
|
|
268
|
+
`pi-harness run` now also starts lightweight local web UI for orchestration flow by default. By default it listens on `127.0.0.1:4317`. Override with `PI_VISUALIZER_HOST` and `PI_VISUALIZER_PORT`. Set `PI_VISUALIZER=0` to disable embedded web UI for a run.
|
|
269
|
+
|
|
270
|
+
Visualizer uses SSE for live updates instead of browser polling.
|
|
271
|
+
|
|
272
|
+
`pi-harness visualize` still exists as standalone viewer if you want to inspect run history without starting a new run.
|
|
268
273
|
|
|
269
274
|
Visualizer now includes:
|
|
270
275
|
- run history selector from `.pi-runtime/runs/`
|
package/SETUP.md
CHANGED
|
@@ -83,6 +83,7 @@ Minimal example:
|
|
|
83
83
|
5. Add package scripts.
|
|
84
84
|
|
|
85
85
|
- `pi:once` and `pi:run` should use default `sdk` transport unless the repo has a very specific reason not to.
|
|
86
|
+
- `pi:run` will also host local orchestration web UI by default.
|
|
86
87
|
- `pi:mock` is for setup validation when real PI execution is not ready yet.
|
|
87
88
|
|
|
88
89
|
Add these scripts to the consuming repo `package.json`, adapting only if necessary:
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -53,9 +53,11 @@ pi-harness visualize
|
|
|
53
53
|
|
|
54
54
|
The package reads `PI_CONFIG_FILE` if provided. Otherwise it falls back to the bundled generic `pi.config.json`.
|
|
55
55
|
|
|
56
|
-
`pi-harness
|
|
56
|
+
`pi-harness run` also starts SSE-backed web UI over local HTTP by default. Defaults: `PI_VISUALIZER_HOST=127.0.0.1`, `PI_VISUALIZER_PORT=4317`. Set `PI_VISUALIZER=0` to disable it for a run.
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
`pi-harness visualize` remains available as standalone viewer.
|
|
59
|
+
|
|
60
|
+
Visualizer reads active-run lock, per-run state, per-run iteration summary, per-run last output snapshot, and telemetry to show current stage plus historical runs.
|
|
59
61
|
|
|
60
62
|
## Config Contract
|
|
61
63
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebastianandreasson/pi-autonomous-agents",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Portable unattended PI harness for developer/tester/visual-review loops.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"@mariozechner/pi-coding-agent": "^0.66.1"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-sdk-turn.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/pi-visualizer.mjs && node --check src/pi-visualizer-shared.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-sdk-supervisor.test.mjs && node --check test/pi-sdk-turn.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/pi-visualizer-shared.test.mjs && node --check test/fixtures/fake-pi.mjs && node --check test/fixtures/fake-pi-sdk.mjs",
|
|
22
|
+
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-sdk-turn.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/pi-visualizer.mjs && node --check src/pi-visualizer-server.mjs && node --check src/pi-visualizer-shared.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-sdk-supervisor.test.mjs && node --check test/pi-sdk-turn.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/pi-visualizer-shared.test.mjs && node --check test/fixtures/fake-pi.mjs && node --check test/fixtures/fake-pi-sdk.mjs",
|
|
23
23
|
"test": "node --test test/pi-heartbeat.test.mjs test/pi-lifecycle.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-sdk-supervisor.test.mjs test/pi-sdk-turn.test.mjs test/pi-telemetry.test.mjs test/pi-visualizer-shared.test.mjs"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
package/src/index.mjs
CHANGED
|
@@ -14,3 +14,4 @@ export { collectLargeFileWarnings } from './pi-repo.mjs'
|
|
|
14
14
|
export { runAgentTurn } from './pi-client.mjs'
|
|
15
15
|
export { createSdkSession, createTools, normalizeToolNames, resolveModel, runSdkTurn, runSdkTurnWithPi, splitModelSpec } from './pi-sdk-turn.mjs'
|
|
16
16
|
export { deriveCurrentIteration, deriveFlowSnapshot, deriveStageGraph, formatActiveLabel, getFlowSteps, getLabelForKind, getStepKeyForActiveRun, getStepKeyForKind } from './pi-visualizer-shared.mjs'
|
|
17
|
+
export { buildSnapshot, readVisualizerHost, readVisualizerPort, renderHtml, startVisualizerServer } from './pi-visualizer-server.mjs'
|
package/src/pi-supervisor.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
shouldPersistLatestTesterFeedback,
|
|
50
50
|
} from './pi-flow.mjs'
|
|
51
51
|
import { runStartupPreflight } from './pi-preflight.mjs'
|
|
52
|
+
import { startVisualizerServer } from './pi-visualizer-server.mjs'
|
|
52
53
|
|
|
53
54
|
let stopRequested = false
|
|
54
55
|
let shutdownEscalationTimer = null
|
|
@@ -1738,6 +1739,7 @@ async function main() {
|
|
|
1738
1739
|
await ensureFileExists(config.taskFile, 'task file')
|
|
1739
1740
|
await ensureFileExists(config.developerInstructionsFile, 'developer instructions file')
|
|
1740
1741
|
await ensureFileExists(config.testerInstructionsFile, 'tester instructions file')
|
|
1742
|
+
let visualizer = null
|
|
1741
1743
|
const lockResult = await acquireRunLock(config.activeRunFile, {
|
|
1742
1744
|
runId,
|
|
1743
1745
|
pid: process.pid,
|
|
@@ -1756,6 +1758,16 @@ async function main() {
|
|
|
1756
1758
|
process.env.PI_RUN_LOG_FILE = config.runLogFile
|
|
1757
1759
|
await ensureTelemetryFiles(config)
|
|
1758
1760
|
await appendLog(config.logFile, `Run started pid=${process.pid} mode=${config.mode}`)
|
|
1761
|
+
if (config.mode === 'run' && process.env.PI_VISUALIZER !== '0' && process.env.PI_VISUALIZER !== 'false') {
|
|
1762
|
+
try {
|
|
1763
|
+
visualizer = await startVisualizerServer(config)
|
|
1764
|
+
await appendLog(config.logFile, `Visualizer started at ${visualizer.url}`)
|
|
1765
|
+
process.stderr.write(`[PI visualizer] ${visualizer.url}\n`)
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
await appendLog(config.logFile, `Visualizer failed to start: ${error instanceof Error ? error.message : String(error)}`)
|
|
1768
|
+
process.stderr.write(`[PI visualizer] failed to start: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1759
1771
|
if (lockResult.staleLock) {
|
|
1760
1772
|
await appendLog(
|
|
1761
1773
|
config.logFile,
|
|
@@ -1813,6 +1825,9 @@ async function main() {
|
|
|
1813
1825
|
activeRole: '',
|
|
1814
1826
|
activeReason: '',
|
|
1815
1827
|
})
|
|
1828
|
+
if (visualizer) {
|
|
1829
|
+
await visualizer.close().catch(() => {})
|
|
1830
|
+
}
|
|
1816
1831
|
await releaseRunLock(config.activeRunFile, runId)
|
|
1817
1832
|
delete process.env.PI_RUN_ID
|
|
1818
1833
|
delete process.env.PI_RUN_LOG_FILE
|
|
@@ -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
|
+
}
|
package/src/pi-visualizer.mjs
CHANGED
|
@@ -1,473 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import fs from 'node:fs/promises'
|
|
4
|
-
import http from 'node:http'
|
|
5
|
-
import path from 'node:path'
|
|
6
3
|
import process from 'node:process'
|
|
7
4
|
import { loadConfig } from './pi-config.mjs'
|
|
8
|
-
import {
|
|
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
|
-
}
|
|
5
|
+
import { startVisualizerServer } from './pi-visualizer-server.mjs'
|
|
440
6
|
|
|
441
7
|
async function main() {
|
|
442
8
|
const config = loadConfig('once')
|
|
443
|
-
const
|
|
444
|
-
|
|
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
|
-
})
|
|
9
|
+
const visualizer = await startVisualizerServer(config)
|
|
10
|
+
process.stdout.write(`PI Harness visualizer listening on ${visualizer.url}\n`)
|
|
471
11
|
}
|
|
472
12
|
|
|
473
13
|
main().catch((error) => {
|