POST /api/sessions/:id/reveal resolves the file server-side (execFile, argv array — no shell) and opens the platform file manager; the header button shows the full path on hover.
474 lines
17 KiB
JavaScript
474 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
const { execFile } = require('node:child_process')
|
|
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),
|
|
}
|
|
}
|
|
|
|
// Reveal a file in the OS file manager. The path always comes from
|
|
// findSessionFile (never from the request), and execFile takes an argv array —
|
|
// no shell, no injection surface.
|
|
function revealInFileManager(file) {
|
|
if (process.platform === 'darwin') execFile('open', ['-R', file], () => {})
|
|
else if (process.platform === 'win32') execFile('explorer', [`/select,${file}`], () => {})
|
|
else execFile('xdg-open', [path.dirname(file)], () => {})
|
|
}
|
|
|
|
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('/reveal')) {
|
|
const id = suffix.slice(0, -'/reveal'.length)
|
|
const file = findSessionFile(id)
|
|
if (!file) return send(res, 404, { error: `session not found: ${id}` })
|
|
revealInFileManager(file)
|
|
return send(res, 200, { ok: true, path: file })
|
|
}
|
|
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}`)
|
|
})
|