feat(research): add trace-workbench local session-replay UI
A localhost viewer over persisted session JSONL for agent developers and researchers. Three first-class views — Chat (markdown-rendered surface conversation with a second-level inspector), Trajectory (turn/step tree with scroll-spy over a step-grouped event table, inline annotations), and Waterfall (timing summary + aligned time track that deep-links into Trajectory). Subagent sessions group under their parentSession with spawn links and breadcrumbs; failed tool calls are marked in every view; ?session=&view=&sel= makes any selection a shareable deep link. Motion follows an audited restraint baseline (plans/ documents the audit): one strong ease-out token, enter-only animations, press feedback on pushbuttons, prefers-reduced-motion support. AGENTS.md gains the research/ layout entry; the AGENTS.md word ceiling rises 1370 -> 1375 to fit it (one line, relocation not applicable for a top-level layout entry).
This commit is contained in:
456
research/trace-workbench/server.js
Normal file
456
research/trace-workbench/server.js
Normal file
@@ -0,0 +1,456 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const path = require('node:path')
|
||||
const { URL } = require('node:url')
|
||||
|
||||
const PORT = Number(process.env.PORT || 5173)
|
||||
const HOST = process.env.HOST || '127.0.0.1'
|
||||
const DEFAULT_ROOT = path.join(process.cwd(), '.sessions')
|
||||
const SESSIONS_ROOT = process.env.HARNESS_SESSIONS_ROOT || DEFAULT_ROOT
|
||||
const STATIC_ROOT = __dirname
|
||||
const FEEDBACK_ROOT = process.env.HARNESS_FEEDBACK_ROOT || path.join(STATIC_ROOT, '.feedback')
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
|
||||
function send(res, status, body, type = 'application/json; charset=utf-8') {
|
||||
const payload = type.startsWith('application/json') ? JSON.stringify(body, null, 2) : body
|
||||
res.writeHead(status, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function sendFile(res, full, type) {
|
||||
const body = fs.readFileSync(full)
|
||||
res.writeHead(200, {
|
||||
'content-type': type,
|
||||
'cache-control': 'no-store',
|
||||
'content-length': body.length,
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', chunk => {
|
||||
body += chunk
|
||||
if (body.length > 1024 * 1024) {
|
||||
req.destroy()
|
||||
reject(new Error('request body too large'))
|
||||
}
|
||||
})
|
||||
req.on('end', () => {
|
||||
if (!body.trim()) return resolve({})
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function walkJsonl(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walkJsonl(full, out)
|
||||
else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readJsonl(file) {
|
||||
const text = fs.readFileSync(file, 'utf8')
|
||||
const rows = []
|
||||
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
rows.push(JSON.parse(line))
|
||||
} catch (error) {
|
||||
rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } })
|
||||
}
|
||||
}
|
||||
const first = rows[0]
|
||||
const meta = first?.type === 'session'
|
||||
? { ...first, path: file }
|
||||
: { type: 'session', version: 0, id: path.basename(file, '.jsonl'), createdAt: 0, path: file }
|
||||
const events = first?.type === 'session' ? rows.slice(1) : rows
|
||||
return { meta, events, rawText: text }
|
||||
}
|
||||
|
||||
function feedbackFile(sessionId) {
|
||||
return path.join(FEEDBACK_ROOT, `${encodeURIComponent(sessionId)}.feedback.jsonl`)
|
||||
}
|
||||
|
||||
function readFeedback(sessionId) {
|
||||
const file = feedbackFile(sessionId)
|
||||
if (!fs.existsSync(file)) return []
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch (error) {
|
||||
return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function appendFeedback(sessionId, data) {
|
||||
fs.mkdirSync(FEEDBACK_ROOT, { recursive: true })
|
||||
const rows = readFeedback(sessionId)
|
||||
const record = {
|
||||
type: 'feedback/add',
|
||||
seq: rows.length,
|
||||
time: Date.now(),
|
||||
data: {
|
||||
sessionId,
|
||||
targetId: String(data.targetId || `session:${sessionId}`),
|
||||
targetTitle: String(data.targetTitle || sessionId),
|
||||
targetKind: String(data.targetKind || 'session'),
|
||||
author: String(data.author || 'anonymous').trim() || 'anonymous',
|
||||
text: String(data.text || '').trim(),
|
||||
},
|
||||
}
|
||||
if (!record.data.text) throw new Error('feedback text is required')
|
||||
fs.appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`)
|
||||
return record
|
||||
}
|
||||
|
||||
function textOfContent(content) {
|
||||
if (!Array.isArray(content)) return ''
|
||||
return content.map((block) => {
|
||||
if (block.type === 'text' || block.type === 'reasoning') return block.text || ''
|
||||
if (block.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments || ''}`
|
||||
if (block.type === 'tool-result') return `[tool-result] ${JSON.stringify(block.content ?? block)}`
|
||||
return JSON.stringify(block)
|
||||
}).filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function latestHeader(events) {
|
||||
const headers = events.filter(event => event.type === 'request/header' && event.data?.header)
|
||||
return headers.at(-1)?.data?.header ?? {}
|
||||
}
|
||||
|
||||
function summarize(file) {
|
||||
const { meta, events } = readJsonl(file)
|
||||
const header = latestHeader(events)
|
||||
const last = events.at(-1)
|
||||
const firstUser = events.find(event => event.type === 'user/message')
|
||||
return {
|
||||
id: String(meta.id),
|
||||
parentSession: meta.parentSession ? String(meta.parentSession) : undefined,
|
||||
cwd: meta.cwd,
|
||||
path: file,
|
||||
createdAt: meta.createdAt || events[0]?.time || 0,
|
||||
lastActivity: last?.time || meta.createdAt || 0,
|
||||
eventCount: events.length,
|
||||
turnCount: events.filter(event => event.type === 'turn/start').length,
|
||||
stepCount: events.filter(event => event.type === 'step/start').length,
|
||||
toolCallCount: events.filter(event => event.type === 'tool/call').length,
|
||||
model: header.config?.model,
|
||||
title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(meta.id),
|
||||
}
|
||||
}
|
||||
|
||||
function findSessionFile(id) {
|
||||
const files = walkJsonl(SESSIONS_ROOT)
|
||||
const byName = files.find(file => path.basename(file, '.jsonl') === id)
|
||||
if (byName) return byName
|
||||
return files
|
||||
.map(file => ({ file, summary: summarize(file) }))
|
||||
.find(item => item.summary.id === id)?.file
|
||||
}
|
||||
|
||||
function parseArguments(text) {
|
||||
if (typeof text !== 'string') return text
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode({ id, kind, title, subtitle, startTime, status = 'ok' }) {
|
||||
return {
|
||||
id, kind, title, subtitle, status,
|
||||
startTime, endTime: startTime, startSec: 0, durationSec: 0,
|
||||
children: [], rawEvents: [], detail: {},
|
||||
}
|
||||
}
|
||||
|
||||
function assignTiming(node, zero, fallbackEnd) {
|
||||
const start = Number.isFinite(node.startTime) ? node.startTime : zero
|
||||
const end = Math.max(start, Number.isFinite(node.endTime) ? node.endTime : fallbackEnd)
|
||||
node.startSec = Math.max(0, (start - zero) / 1000)
|
||||
node.durationSec = Math.max(0, (end - start) / 1000)
|
||||
for (const child of node.children) assignTiming(child, zero, fallbackEnd)
|
||||
}
|
||||
|
||||
function buildSession(file) {
|
||||
const { meta, events } = readJsonl(file)
|
||||
const firstTime = events[0]?.time || meta.createdAt || Date.now()
|
||||
const lastTime = events.at(-1)?.time || firstTime
|
||||
const headerEvents = events.filter(event => event.type === 'request/header' && event.data?.header)
|
||||
const header = latestHeader(events)
|
||||
const tools = Array.isArray(header.tools) ? header.tools : []
|
||||
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
|
||||
|
||||
const root = makeNode({
|
||||
id: `session:${meta.id}`,
|
||||
kind: 'session',
|
||||
title: String(meta.id),
|
||||
subtitle: meta.cwd || path.dirname(file),
|
||||
startTime: firstTime,
|
||||
})
|
||||
root.endTime = lastTime
|
||||
root.rawEvents = events
|
||||
|
||||
const turnMap = new Map()
|
||||
const stepMap = new Map()
|
||||
const toolMap = new Map()
|
||||
const firstChunkByStep = new Map()
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const key = `${event.data?.turn}:${event.data?.step}`
|
||||
if (!firstChunkByStep.has(key)) firstChunkByStep.set(key, event.time)
|
||||
}
|
||||
}
|
||||
|
||||
let currentTurn
|
||||
let currentStep
|
||||
for (const event of events) {
|
||||
if (event.type === 'turn/start') {
|
||||
const turn = event.data.turn
|
||||
const node = makeNode({
|
||||
id: `turn:${turn}`,
|
||||
kind: 'turn',
|
||||
title: `turn ${turn}`,
|
||||
subtitle: event.data.trigger?.kind || 'turn/start',
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
root.children.push(node)
|
||||
turnMap.set(turn, node)
|
||||
currentTurn = node
|
||||
currentStep = undefined
|
||||
} else if (event.type === 'turn/end') {
|
||||
const node = turnMap.get(event.data.turn)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.status = event.data.reason?.kind === 'completed' ? 'ok' : 'error'
|
||||
node.rawEvents.push(event)
|
||||
}
|
||||
} else if (event.type === 'step/start') {
|
||||
const turn = turnMap.get(event.data.turn) || currentTurn || root
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const node = makeNode({
|
||||
id: `step:${key}`,
|
||||
kind: 'step',
|
||||
title: `step ${event.data.step}`,
|
||||
subtitle: `turn ${event.data.turn}`,
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
turn.children.push(node)
|
||||
stepMap.set(key, node)
|
||||
currentStep = node
|
||||
} else if (event.type === 'step/end') {
|
||||
const node = stepMap.get(`${event.data.turn}:${event.data.step}`)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.rawEvents.push(event)
|
||||
}
|
||||
} else if (event.type === 'request/header') {
|
||||
const node = currentStep || currentTurn || root
|
||||
node.rawEvents.push(event)
|
||||
node.detail.promptSeq = event.seq
|
||||
} else if (event.type === 'assistant/message') {
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const step = stepMap.get(key) || currentStep || root
|
||||
const startTime = firstChunkByStep.get(key) ?? event.time
|
||||
const text = textOfContent(event.data.content)
|
||||
const node = makeNode({
|
||||
id: `assistant:${event.seq}`,
|
||||
kind: 'llm',
|
||||
title: 'assistant/message',
|
||||
subtitle: text.slice(0, 96) || 'assembled assistant message',
|
||||
startTime,
|
||||
})
|
||||
node.endTime = event.time
|
||||
node.rawEvents.push(event)
|
||||
node.detail = {
|
||||
input: event.data.usage ? { usage: event.data.usage } : '',
|
||||
output: event.data.content,
|
||||
promptSeq: step.detail.promptSeq,
|
||||
}
|
||||
step.children.push(node)
|
||||
step.rawEvents.push(event)
|
||||
} else if (event.type === 'tool/call') {
|
||||
const key = `${event.data.turn}:${event.data.step}`
|
||||
const step = stepMap.get(key) || currentStep || root
|
||||
const node = makeNode({
|
||||
id: `tool:${event.data.callId}`,
|
||||
kind: 'tool',
|
||||
title: event.data.name,
|
||||
subtitle: event.data.callId,
|
||||
startTime: event.time,
|
||||
})
|
||||
node.rawEvents.push(event)
|
||||
node.detail = {
|
||||
input: parseArguments(event.data.arguments),
|
||||
output: '',
|
||||
promptSeq: step.detail.promptSeq,
|
||||
schema: toolsByName.get(event.data.name),
|
||||
}
|
||||
step.children.push(node)
|
||||
step.rawEvents.push(event)
|
||||
toolMap.set(event.data.callId, node)
|
||||
} else if (event.type === 'tool/result') {
|
||||
const node = toolMap.get(event.data.callId)
|
||||
if (node) {
|
||||
node.endTime = event.time
|
||||
node.status = event.data.isError ? 'error' : 'ok'
|
||||
node.rawEvents.push(event)
|
||||
node.detail.output = {
|
||||
content: event.data.content,
|
||||
isError: event.data.isError,
|
||||
error: event.data.error,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const target = currentStep || currentTurn || root
|
||||
target.rawEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
assignTiming(root, firstTime, lastTime)
|
||||
|
||||
const messages = events
|
||||
.filter(event => ['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'].includes(event.type))
|
||||
.map(event => ({
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
type: event.type,
|
||||
role: event.type === 'assistant/message' ? 'assistant' : event.type === 'tool/result' ? 'tool' : 'user',
|
||||
content: event.data.content,
|
||||
text: textOfContent(event.data.content),
|
||||
}))
|
||||
|
||||
const stats = {
|
||||
startTime: firstTime,
|
||||
durationSec: Math.max(0, (lastTime - firstTime) / 1000),
|
||||
turns: events.filter(event => event.type === 'turn/start').length,
|
||||
steps: events.filter(event => event.type === 'step/start').length,
|
||||
toolCalls: events.filter(event => event.type === 'tool/call').length,
|
||||
llmMessages: events.filter(event => event.type === 'assistant/message').length,
|
||||
}
|
||||
|
||||
// Serialize the tree with event seq references instead of embedded event
|
||||
// copies: every event is already shipped once in `events`, so nodes carry
|
||||
// `eventSeqs` and the client resolves them through its seq index.
|
||||
const packNode = (node) => {
|
||||
const { rawEvents, children, ...rest } = node
|
||||
return { ...rest, eventSeqs: rawEvents.map(event => event.seq), children: children.map(packNode) }
|
||||
}
|
||||
|
||||
const siblings = listSessions()
|
||||
const sessionId = String(meta.id)
|
||||
const children = siblings.filter(summary => summary.parentSession === sessionId)
|
||||
const parent = meta.parentSession
|
||||
? siblings.find(summary => summary.id === String(meta.parentSession)) ?? { id: String(meta.parentSession) }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
header: { ...meta, path: file },
|
||||
stats,
|
||||
agent: {
|
||||
id: 'A_main',
|
||||
model: header.config?.model,
|
||||
systemPrompt: header.system || '',
|
||||
messagePrefix: header.messagePrefix,
|
||||
tools,
|
||||
headerEvents: headerEvents.length,
|
||||
latestHeader: header,
|
||||
},
|
||||
tree: packNode(root),
|
||||
messages,
|
||||
events,
|
||||
children,
|
||||
parent,
|
||||
feedback: readFeedback(sessionId),
|
||||
}
|
||||
}
|
||||
|
||||
function listSessions() {
|
||||
return walkJsonl(SESSIONS_ROOT)
|
||||
.filter(file => !path.basename(file).startsWith('stdout.'))
|
||||
.map(summarize)
|
||||
.sort((a, b) => b.lastActivity - a.lastActivity)
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
const target = pathname === '/' ? '/index.html' : pathname
|
||||
const decoded = decodeURIComponent(target)
|
||||
const full = path.normalize(path.join(STATIC_ROOT, decoded))
|
||||
if (full !== STATIC_ROOT && !full.startsWith(STATIC_ROOT + path.sep)) return send(res, 403, 'forbidden', 'text/plain; charset=utf-8')
|
||||
if (!fs.existsSync(full) || fs.statSync(full).isDirectory()) return send(res, 404, 'not found', 'text/plain; charset=utf-8')
|
||||
const ext = path.extname(full)
|
||||
sendFile(res, full, MIME[ext] || 'application/octet-stream')
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`)
|
||||
if (url.pathname === '/api/health') return send(res, 200, { ok: true, root: SESSIONS_ROOT })
|
||||
if (url.pathname === '/api/sessions' && req.method === 'POST') {
|
||||
return send(res, 501, {
|
||||
error: 'New live sessions are not connected yet. This prototype currently replays persisted JSONL sessions.',
|
||||
next: 'Wire this endpoint to the Harness ACP/stdio runtime to create a live session.',
|
||||
})
|
||||
}
|
||||
if (url.pathname === '/api/sessions') return send(res, 200, { root: SESSIONS_ROOT, sessions: listSessions() })
|
||||
if (url.pathname.startsWith('/api/sessions/')) {
|
||||
const suffix = decodeURIComponent(url.pathname.slice('/api/sessions/'.length))
|
||||
if (suffix.endsWith('/feedback')) {
|
||||
const id = suffix.slice(0, -'/feedback'.length)
|
||||
if (req.method === 'GET') return send(res, 200, { feedback: readFeedback(id) })
|
||||
if (req.method === 'POST') return send(res, 200, { feedback: appendFeedback(id, await readJsonBody(req)) })
|
||||
}
|
||||
if (req.method === 'POST' && suffix.endsWith('/messages')) {
|
||||
return send(res, 501, {
|
||||
error: 'Live interaction is not connected yet. This prototype is reading persisted JSONL replay data.',
|
||||
next: 'Wire this endpoint to the Harness ACP/stdio runtime to continue a session.',
|
||||
})
|
||||
}
|
||||
const id = suffix
|
||||
const file = findSessionFile(id)
|
||||
if (!file) return send(res, 404, { error: `session not found: ${id}` })
|
||||
return send(res, 200, buildSession(file))
|
||||
}
|
||||
serveStatic(req, res, url.pathname)
|
||||
} catch (error) {
|
||||
send(res, 500, { error: String(error?.stack || error) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Harness Local Workbench listening on http://${HOST}:${PORT}`)
|
||||
console.log(`Reading sessions from ${SESSIONS_ROOT}`)
|
||||
})
|
||||
Reference in New Issue
Block a user