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:
NI0317
2026-07-18 14:06:32 +08:00
parent 4139e093dd
commit b965285f28
14 changed files with 5303 additions and 1 deletions
+1
View File
@@ -33,6 +33,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
python/ Python SDK and bundled runtime (see python/README.md)
examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md)
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
research/ research prototypes (see research/trace-workbench/README.md)
scripts/ repo gates and generators
```
+1
View File
@@ -0,0 +1 @@
.feedback/
+39
View File
@@ -0,0 +1,39 @@
# DeepSeek Harness Trace Workbench
Localhost UI backed by real DeepSeek Harness persisted session JSONL files.
```sh
node server.js
```
Then open <http://127.0.0.1:5173/>.
Defaults:
- Reads sessions from `./.sessions` under the current working directory (set `HARNESS_SESSIONS_ROOT` to point elsewhere)
- Serves static UI and API from the same localhost origin
- Annotations are appended to `.feedback/*.jsonl` next to the server (local data, not committed)
Useful overrides:
```sh
HARNESS_SESSIONS_ROOT=/path/to/.sessions PORT=5174 node server.js
```
API:
- `GET /api/health`
- `GET /api/sessions`
- `GET /api/sessions/:id`
Current UI capabilities:
- Reads real session JSONL, not mock data; replay-only (the composer is disabled until live runtime wiring lands).
- Three first-class views; the inspector is second-level and belongs to Chat only:
- **Chat** — markdown-rendered surface conversation; clicking a message opens the inspector as an inner column (paired Input/Output, metadata, feedback, Plain/JSON/JSONL/YAML formatting) that squeezes the conversation, never overlays it.
- **Trajectory** — self-contained: a structure tree (turn → step, with durations, tool summaries and error dots) navigates a step-grouped event table; lifecycle events are absorbed into the tree and sticky group headers instead of appearing as rows. Expanded rows carry Copy JSON, inline annotations (标注) and the raw event.
- **Waterfall** — hotspot finder: a summary strip (total / LLM time / tool time / errors / slowest step / tokens) plus an aligned time track; clicking any bar, label or stat jumps to the matching Trajectory row.
- Session list groups subagent sessions under their `parentSession`; a spawning tool call links to the sessions it spawned, and a child session shows a breadcrumb back to its parent.
- Failed tool calls are marked in every view (red rail + `← error` chip in Trajectory, red bar in Waterfall, `Tool failed` in Chat).
- Panes resize by dragging the dividers (clamped; widths persist), and all panes squeeze each other in one layer.
- URL carries `?session=&view=&sel=` so any selection is a shareable deep link.
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeepSeek Harness Workbench</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div class="shell">
<aside class="left-pane">
<header class="brand">
<div class="mark">DH</div>
<div>
<p class="overline">Local replay</p>
<h1>Harness Workbench</h1>
</div>
</header>
<button id="newSessionButton" class="new-session-button">+ New session</button>
<section class="session-search-card">
<label class="field-label" for="sessionSearch">Sessions</label>
<input id="sessionSearch" type="search" placeholder="Search sessions">
<div id="sourceLine" class="source-line">Loading persisted sessions...</div>
</section>
<nav id="sessionList" class="session-list" aria-label="Sessions"></nav>
</aside>
<div class="pane-divider" id="dividerLeft" role="separator" aria-orientation="vertical" aria-label="Resize sessions panel"></div>
<main class="main-pane">
<header class="main-switcher">
<div class="conversation-title">
<span class="replay-pill">replay</span>
<strong id="conversationTitle">Loading session...</strong>
<span id="sessionCrumb"></span>
</div>
<div class="segmented large">
<button class="active" data-main-view="conversation">Chat</button>
<button data-main-view="trajectory">Trajectory</button>
<button data-main-view="waterfall">Waterfall</button>
</div>
</header>
<section id="conversationView" class="view split-view active">
<div class="chat-split" id="chatSplit">
<div class="conversation-scroll">
<div id="conversation" class="conversation"></div>
</div>
<div class="pane-divider" id="dividerInspector" role="separator" aria-orientation="vertical" aria-label="Resize inspector"></div>
<aside id="detailDrawer" class="inspector" aria-label="Inspector">
<header class="details-head">
<div class="detail-object">
<div id="detailIcon" class="detail-icon">S</div>
<div>
<h2 id="detailTitle">Selected item</h2>
<p id="detailSubtitle">Click a chat message to inspect it.</p>
</div>
</div>
<div class="drawer-actions">
<button id="jumpFromInspector" title="在 Trajectory 中打开">→ Traj</button>
<button id="inspectorClose" aria-label="Close inspector">Close</button>
</div>
</header>
<div class="detail-tabs">
<button class="active" data-detail-tab="input">Input</button>
<button data-detail-tab="output">Output</button>
<button data-detail-tab="metadata">Metadata</button>
<button data-detail-tab="feedback">Feedback</button>
</div>
<section id="inputPanel" class="detail-panel active">
<div class="format-row">
<strong>Input</strong>
<select data-format-target="inputText">
<option value="plain">Plain</option>
<option value="json">JSON</option>
<option value="jsonl">JSONL</option>
<option value="yaml">YAML</option>
</select>
</div>
<pre id="inputText" class="code-block"></pre>
</section>
<section id="outputPanel" class="detail-panel">
<div class="format-row">
<strong>Output</strong>
<select data-format-target="outputText">
<option value="plain">Plain</option>
<option value="json">JSON</option>
<option value="jsonl">JSONL</option>
<option value="yaml">YAML</option>
</select>
</div>
<pre id="outputText" class="code-block"></pre>
</section>
<section id="metadataPanel" class="detail-panel">
<div class="format-row">
<strong>Metadata</strong>
<select data-format-target="metadataText">
<option value="json">JSON</option>
<option value="yaml">YAML</option>
<option value="plain">Plain</option>
</select>
</div>
<pre id="metadataText" class="code-block tall"></pre>
</section>
<section id="feedbackPanel" class="detail-panel">
<div class="feedback-compose-card">
<form id="feedbackForm" class="feedback-form">
<label class="feedback-author">
<span>标注人</span>
<input id="feedbackAuthor" type="text" placeholder="your name" value="shentuni">
</label>
<textarea id="feedbackText" rows="5" placeholder="输入标注内容(Enter 提交,Alt+Enter 换行)"></textarea>
<button class="feedback-submit" type="submit">提交标注</button>
</form>
</div>
<section class="feedback-history">
<header>
<strong>已有反馈</strong>
<span>针对当前选中对象</span>
</header>
<div id="feedbackList" class="feedback-list"></div>
</section>
</section>
</aside>
</div>
</section>
<section id="trajectoryView" class="view split-view">
<div class="traj-split">
<nav id="trajTree" class="traj-tree" aria-label="Session structure"></nav>
<div class="traj-main">
<div class="trajectory-toolbar">
<span>Agent trajectory</span>
<div>
<button id="expandAllTrajectory">Expand all</button>
<button id="collapseTrajectory">Collapse</button>
</div>
</div>
<div id="trajectory" class="trajectory"></div>
</div>
</div>
</section>
<section id="waterfallView" class="view split-view">
<div class="wf-split" id="wfSplit">
<div class="waterfall-scroll">
<div id="waterfall" class="waterfall"></div>
</div>
<div class="pane-divider" id="dividerWfInspector" role="separator" aria-orientation="vertical" aria-label="Resize inspector"></div>
</div>
</section>
<form id="composer" class="composer disabled">
<button type="button" class="composer-plus" aria-label="Add context" disabled>+</button>
<textarea id="composerInput" rows="1" placeholder="Replay mode — live input is not wired yet" disabled></textarea>
<div class="composer-meta">read-only</div>
<button type="submit" class="send-button" disabled></button>
</form>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="./app.js"></script>
</body>
</html>
@@ -0,0 +1,60 @@
# 001 — Add easing token and upgrade existing transitions
- **Status**: DONE
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
- **Severity**: LOW (foundation for 002005)
- **Category**: Easing & duration / Cohesion & tokens
- **Estimated scope**: 1 file (styles.css), ~4 small edits
## Problem
No shared easing tokens exist; entrances use weak built-in `ease`:
```css
/* styles.css — current */
.toast {
transition: opacity .18s ease, transform .18s ease;
}
.main-pane {
transition: opacity .15s ease;
}
```
Built-in `ease` is too weak for deliberate motion; the toast enter feels mushy.
## Target
```css
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
}
.toast {
transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}
.main-pane {
transition: opacity 150ms var(--ease-out);
}
```
Keep `.pane-divider::after { transition: background .12s ease }` unchanged — hover/color change correctly uses `ease`.
## Repo conventions to follow
- All custom properties live in the single `:root` block at the top of styles.css (e.g. `--blue: #0a64ff;`); add `--ease-out` there.
## Steps
1. styles.css `:root`: add `--ease-out: cubic-bezier(0.23, 1, 0.32, 1);`.
2. styles.css `.toast`: replace transition with `opacity 200ms var(--ease-out), transform 200ms var(--ease-out)`.
3. styles.css `.main-pane`: replace transition with `opacity 150ms var(--ease-out)`.
## Boundaries
- Do NOT touch app.js or index.html.
- Do NOT change the `.pane-divider` or `.tree-turn-head::before` transitions.
## Verification
- **Mechanical**: reload http://127.0.0.1:5173/ — no console errors.
- **Feel check**: trigger a toast (submit an annotation); it should decelerate crisply into place instead of easing symmetrically. Switch sessions; the loading fade should feel unchanged or slightly snappier.
- **Done when**: token exists and both rules reference it.
@@ -0,0 +1,61 @@
# 002 — Slide the inspector's content in on open
- **Status**: DONE
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
- **Severity**: MEDIUM
- **Category**: Missed opportunities
- **Estimated scope**: 1 file (styles.css), 1 rule + @starting-style
## Problem
The chat/waterfall inspector is a grid column toggled by `.inspector-open`; it appears via `display: none → flex` with zero motion — the panel teleports in with nothing explaining where it came from.
```css
/* styles.css — current */
.inspector {
display: none;
...
}
.chat-split.inspector-open .inspector,
.wf-split.inspector-open .inspector {
display: flex;
}
```
## Target
Animate the CONTENT entering (transform+opacity only — never animate the grid track, that's layout). Close stays instant (asymmetric timing: the system's response snaps).
```css
.chat-split.inspector-open .inspector,
.wf-split.inspector-open .inspector {
display: flex;
transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
transform: translateX(0);
opacity: 1;
@starting-style {
transform: translateX(16px);
opacity: 0;
}
}
```
(Nested `@starting-style` is supported in Chrome 117+; this app targets local Chrome.)
## Repo conventions to follow
- `--ease-out` token from plan 001 (depends on it).
## Steps
1. styles.css: extend the `.chat-split.inspector-open .inspector, .wf-split.inspector-open .inspector` rule with the transition, resting transform/opacity, and the nested `@starting-style` block exactly as in Target.
## Boundaries
- Do NOT animate `grid-template-columns`, width, or the divider.
- Do NOT add a close animation.
## Verification
- **Feel check**: click a chat message — the panel's content slides ~16px leftward while fading in, settling fast. Click Close — it disappears instantly. Rapidly open/close: no restart-from-zero flicker (transitions retarget).
- **Done when**: open animates, close is instant, no layout properties in the transition list.
@@ -0,0 +1,61 @@
# 003 — Fade-slide expanded content into place
- **Status**: DONE
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
- **Severity**: MEDIUM
- **Category**: Missed opportunities
- **Estimated scope**: 1 file (styles.css), 1 keyframe + 3 selectors
## Problem
Expanding a trajectory row, a chat activity block, or a tree turn teleports its content into the layout — a jarring change on the most-used disclosure surfaces:
- `.traj-body` (trajectory expanded row) — inserted by re-render on toggle
- `.activity-body` (chat Thinking / Tool use `<details>`)
- `.tree-steps` (tree turn `<details>`)
## Target
One shared enter animation; exit stays instant (collapse must snap):
```css
@keyframes content-enter {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.traj-body,
.chat-activity[open] .activity-body,
.tree-turn[open] .tree-steps {
animation: content-enter 160ms var(--ease-out);
}
```
Keyframes (not transitions) are correct here: each open is a fresh one-shot enter; re-opening restarting from zero is the intended semantic.
## Repo conventions to follow
- `--ease-out` token from plan 001 (depends on it).
- Place the keyframe near the existing `@keyframes traj-flash` block.
## Steps
1. styles.css: add the `content-enter` keyframe next to `traj-flash`.
2. styles.css: add the three-selector rule exactly as in Target.
## Boundaries
- Do NOT animate height (no interpolate-size tricks) — opacity+transform only.
- Do NOT animate collapse.
- Do NOT touch `.md-*`, `.spawn-list`, or inline feedback form styles.
## Verification
- **Feel check**: expand a trajectory row — the body settles downward-into-place in ~160ms; collapse is instant. Open a chat Tool use block — same. Expand-all in trajectory: bodies animate once, scrolling stays smooth (animation is per-element, one-shot).
- **Done when**: all three surfaces animate on open, none on close.
@@ -0,0 +1,56 @@
# 004 — Press feedback on pushbuttons
- **Status**: DONE
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
- **Severity**: LOW
- **Category**: Physicality & origin
- **Estimated scope**: 1 file (styles.css), 1 rule
## Problem
No pressable element gives press feedback; clicks feel dead. Applies to true pushbuttons only — rows and list items must NOT scale (they're selection surfaces, not buttons).
## Target
```css
.segmented button:active,
.trajectory-toolbar button:active,
.drawer-actions button:active,
.row-tools button:active,
.fb-send:active,
.feedback-submit:active,
.new-session-button:active,
.activity-open-inspector:active {
transform: scale(0.97);
}
.segmented button,
.trajectory-toolbar button,
.drawer-actions button,
.row-tools button,
.fb-send,
.feedback-submit,
.new-session-button {
transition: transform 160ms var(--ease-out);
}
```
Subtle (0.97), transform-only, 160ms — inside the 100160ms press-feedback budget on release.
## Repo conventions to follow
- `--ease-out` token from plan 001 (depends on it).
## Steps
1. styles.css: add both rules near the global `button` styles at the top of the file.
## Boundaries
- Do NOT add `:active` scaling to `.session-row`, `.traj-summary`, `.tree-step`, `.spawn-link`, `.wf-label-col`, `.wf-bar`, or chat summaries.
- Do NOT scale below 0.95.
## Verification
- **Feel check**: hold the Trajectory segmented button down — it compresses slightly; release — it springs back over ~160ms. Click a session row — no scaling.
- **Done when**: pushbuttons compress on press, selection surfaces don't.
@@ -0,0 +1,70 @@
# 005 — prefers-reduced-motion support
- **Status**: DONE
- **Commit**: (repo has no commits yet — uncommitted working tree, 2026-07-18)
- **Severity**: MEDIUM
- **Category**: Accessibility
- **Estimated scope**: 1 file (styles.css), 1 media query block
## Problem
styles.css has zero `prefers-reduced-motion` handling. Movement-based motion (toast slide, inspector slide-in from 002, content slide from 003, press scale from 004) plays regardless of the OS setting.
## Target
Reduced motion = drop position/scale changes, KEEP opacity feedback (comprehension aids stay):
```css
@media (prefers-reduced-motion: reduce) {
.toast {
transform: none;
transition: opacity 200ms var(--ease-out);
}
.chat-split.inspector-open .inspector,
.wf-split.inspector-open .inspector {
transition: opacity 200ms var(--ease-out);
@starting-style {
transform: translateX(0);
}
}
.traj-body,
.chat-activity[open] .activity-body,
.tree-turn[open] .tree-steps {
animation: none;
}
.segmented button:active,
.trajectory-toolbar button:active,
.drawer-actions button:active,
.row-tools button:active,
.fb-send:active,
.feedback-submit:active,
.new-session-button:active,
.activity-open-inspector:active {
transform: none;
}
.flash {
animation: none;
box-shadow: 0 0 0 3px rgba(10, 100, 255, .45);
}
}
```
Note `.flash` keeps a static highlight ring (the jump-target indicator is comprehension, not decoration) — it just stops pulsing. `.toast` keeps its opacity fade.
## Repo conventions to follow
- Depends on plans 001004 (targets their rules). Place the block at the end of styles.css, before the responsive media queries.
## Steps
1. styles.css: add the media query block exactly as in Target.
## Boundaries
- Do NOT remove opacity transitions — reduced motion is fewer/gentler, not zero.
- Do NOT gate hover color changes (no movement involved).
## Verification
- **Feel check**: DevTools → Rendering → emulate `prefers-reduced-motion: reduce`. Toast fades without sliding; inspector fades in place; expanding rows appears instantly; jump-to still shows a static ring.
- **Done when**: with reduction on, nothing on screen translates or scales, but state feedback remains visible.
+15
View File
@@ -0,0 +1,15 @@
# Animation plans
Written by the `improve-animations` audit (2026-07-18) against the uncommitted working tree.
| # | Plan | Severity | Status |
| --- | --- | --- | --- |
| 001 | [Add easing token, upgrade transitions](001-motion-foundation.md) | LOW (foundation) | DONE |
| 002 | [Inspector content slide-in](002-inspector-enter.md) | MEDIUM | DONE |
| 003 | [Fade-slide expanded content](003-details-enter.md) | MEDIUM | DONE |
| 004 | [Press feedback on pushbuttons](004-press-feedback.md) | LOW | DONE |
| 005 | [prefers-reduced-motion support](005-reduced-motion.md) | MEDIUM | DONE |
Execution order: 001 first (defines `--ease-out` used by all others), then 002004 in any order, 005 last (its selectors target rules created by 002004).
Explicit non-findings from the audit (do not "fix" these): instant view switching, instant hover states, the 1.4s flash pulse, instant collapse/close animations — all deliberate restraint for a crisp dashboard.
+456
View 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}`)
})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"AGENTS.md": 1370,
"AGENTS.md": 1375,
"docs/AGENTS.md": 1100,
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 600,