diff --git a/changelog.d/582-timeline-digest.added.md b/changelog.d/582-timeline-digest.added.md new file mode 100644 index 000000000..7d589d20b --- /dev/null +++ b/changelog.d/582-timeline-digest.added.md @@ -0,0 +1 @@ +A **Copy report** button on the timeline turns the window into a short Markdown run report — per Bot its turns and failures, busy share, tokens, top tools and unanswered requests, then the team rollup and the longest open wait — and copies it with one click, in the same hours the panel shows ([#582](https://github.com/ThinkFlowLab/FlyBot/issues/582)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index c20276c92..438442773 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -28,8 +28,9 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model | `plugins/timeline/src/store.js` | The trace: single writer, write-tmp + fsync + rename, capped segments. | | `plugins/timeline/src/window.js` | Pure read model: records to lanes, turns, tools, arrows, edges, utilization, the token spend per Bot (#357), per-Bot outcomes and recent failures (#471), the open requests waiting longest (#469) and the tools each Bot used (#359). | | `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. | +| `plugins/timeline/src/digest.js` | Team run report (#582): pure `buildDigest(window)` turning the window into short Markdown, with the count and duration formatting (`formatCount`, `formatMs`). | | `plugins/timeline/src/alerts.js` | Durable alerts (#584): pure `createAlertTracker()` folding a window across reads, so a condition opens one alert and resolves when it clears. | -| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). | +| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization, and **Copy report**), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). | | `plugins/timeline/src/sanitize.js` | The cleaners for ids, labels, codes, times, counts. | | `plugins/timeline/src/otel.js` | OTLP export: maps trace records to OpenTelemetry GenAI spans (`spansFrom`, `otlpPayload`) and a cursor exporter (`createExporter`). Off unless `config.exporter` is set. | @@ -40,6 +41,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model - Endpoint `timeline/export` (#357, read-only): the current trace mapped to one OTLP/HTTP JSON payload (`resourceSpans`), the same mapping the timer exporter uses. Ids only unless the body sets `names: true` (#483); `serviceName` overrides the default. The panel or an automation can pull it on demand instead of waiting for the next flush. - Endpoint `timeline/health` (#357, read-only): the same window reduced to per-Bot signals and flags (`errors`, `busy`, `idle`, `waiting`) and a team `ok`; the action view over the numbers the panel draws. Body `{ windowMs?, to? }` like `timeline/window`. - Endpoint `timeline/alerts` (#584, read-only): the same window folded across reads into durable alerts — `errors` (a Bot fails most of its turns), `waiting` (its request is unanswered) and `stuck` (a handoff waited past the threshold) — answered as `{ open, resolved, changed, stale }`. Body `{ windowMs?, to? }` like `timeline/window`. A condition opens ONE alert with its `since` and resolves when it clears; a resolved alert stays readable for a minute after it cleared. +- Endpoint `timeline/digest` (#582, read-only): the same window as a short Markdown run report a person can paste into a chat or a standup — per Bot its turns and failures, busy share, tokens (in/out/cached), top tools and unanswered requests, then the team rollup (turns, failures, tokens, calls) and the longest still-open wait. Body `{ windowMs?, to?, title? }`. It reads only what the window carries: `usage`, `toolUse` and `stuck` when present, and nothing is invented when they are absent (`buildDigest`/`formatCount`/`formatMs` in `src/digest.js`, pure). Window hours are the reader's own clock, the same hours the panel header shows, so what a person copies matches what they see above the button. The panel's **Copy report** button asks for it and copies the Markdown; a page that refuses the clipboard says so instead of reporting a copy. ## Invariants @@ -58,7 +60,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model ## Tests -`plugins/timeline/test/`: panel (geometry, a small hook renderer for open/close, tabs and poll hold, hostile-name negatives), sanitizers, trace store (rotation, retention, lock, crash-safe replace), recorder (negative tests), read model, OTLP export (`otel.test.js`: record→span mapping, payload shape, the cursor and its retry), durable alerts (`alerts.test.js`: one alert per condition, resolution and its grace window, the stuck threshold, the stale look-back, the endpoint and its removal on dispose), and a host test on the real `ctx.flybot` with a mini agent loop on `core/dev/mock-llm.mjs`. Run `npm test` in `plugins/timeline` (CI job `plugins (timeline)`). +`plugins/timeline/test/`: panel (geometry, a small hook renderer for open/close, tabs and poll hold, hostile-name negatives), sanitizers, trace store (rotation, retention, lock, crash-safe replace), recorder (negative tests), read model, OTLP export (`otel.test.js`: record→span mapping, payload shape, the cursor and its retry), durable alerts (`alerts.test.js`: one alert per condition, resolution and its grace window, the stuck threshold, the stale look-back, the endpoint and its removal on dispose), the run report (`digest.test.js`: the pure report, and `timeline/digest` through a real host, read-only and gone after dispose), and a host test on the real `ctx.flybot` with a mini agent loop on `core/dev/mock-llm.mjs`. Run `npm test` in `plugins/timeline` (CI job `plugins (timeline)`). ## Common mistakes diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index f21a99947..eec33d41d 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -17,6 +17,7 @@ window.__ModuleLoader__.load({ factory: (require) => { const { createElement: h, useEffect, useState, useCallback } = require('react') const ENDPOINT = 'timeline/window' + const DIGEST_ENDPOINT = 'timeline/digest' const VERSION = 1 const POLL_MS = 2000 const MAX_BACKOFF_MS = 30_000 @@ -124,10 +125,29 @@ window.__ModuleLoader__.load({ restart() }, refresh: restart, + // The team run report (#582): read-only and on demand — the button asks for it, the poller never + // does, so a report costs one request when a person wants one. + async digest({ title } = {}) { + const asked = { windowMs: state.windowMs, ...(state.to === undefined ? {} : { to: state.to }) } + if (typeof title === 'string' && title.trim()) asked.title = title.trim().slice(0, 80) + const value = await flybot.call(DIGEST_ENDPOINT, asked) + const markdown = typeof value?.markdown === 'string' ? value.markdown : undefined + if (markdown === undefined) throw new Error('the host sent no report') + return { markdown, line: typeof value.line === 'string' ? value.line : undefined } + }, dispose() { disposed = true; timers.clearTimeout(timer); stopWatching?.(); stopWatching = undefined; listeners.clear() }, } } + // ------------------------------------------------------------------ clipboard + // The report is copied on demand, never on a poll. A page can refuse the clipboard (no secure + // context, permission denied): the panel says so rather than pretending it copied. + async function copyText(text) { + const clip = globalThis.navigator?.clipboard + if (!clip || typeof clip.writeText !== 'function') throw new Error('this page has no clipboard') + await clip.writeText(String(text ?? '')) + } + // ------------------------------------------------------------------ formatting // The host cleaned every name already; this is the second line, for a host that did not. const safe = (value, max = 60) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069<>]/g, '').trim().slice(0, max) @@ -597,6 +617,17 @@ window.__ModuleLoader__.load({ useStyles() const [tab, setTab] = useState('lanes') const [compress, setCompress] = useState(false) + const [copy, setCopy] = useState({ status: 'idle' }) + const copyReport = useCallback(async () => { + setCopy({ status: 'busy' }) + try { + const report = await api.digest() + await copyText(report.markdown) + setCopy({ status: 'copied' }) + } catch (error) { + setCopy({ status: 'error', error: safe(error?.message ?? error, 120) }) + } + }, [api]) const [snap, setSnap] = useState(api.state) useEffect(() => { const off = api.subscribe(setSnap) @@ -624,12 +655,13 @@ window.__ModuleLoader__.load({ h('select', { className: 'tl-btn', 'aria-label': 'Window', value: String(snap.windowMs), onChange: event => api.setWindow({ windowMs: Number(event.target.value) }) }, ...WINDOWS.map(([label, ms]) => h('option', { key: ms, value: String(ms) }, `Last ${label}`))), h('button', { className: 'tl-btn', onClick: () => api.setWindow({ windowMs: snap.windowMs, to: (win ? win.to : Date.now()) - snap.windowMs / 2 }) }, 'Earlier'), + h('button', { className: 'tl-btn', 'aria-label': 'Copy report', title: 'Copy this window as a Markdown report', onClick: () => { void copyReport() } }, copy.status === 'copied' ? 'Copied' : copy.status === 'busy' ? 'Copying…' : 'Copy report'), h('button', { className: 'tl-btn live', 'aria-pressed': snap.to === undefined, title: snap.to === undefined ? 'Following the latest activity' : 'Back to the latest activity', onClick: () => { if (snap.to !== undefined) api.setWindow({ windowMs: snap.windowMs }) } }, h('i', { className: 'tl-dot', 'aria-hidden': 'true' }), 'Live'), h('button', { className: 'tl-btn', 'aria-label': 'Close', onClick: onClose }, '✕')), win && h('div', { className: 'tl-range', 'data-flybot': 'timeline-range' }, h('span', { className: 'tl-range-text' }, fmtRange(win.from, win.to)), h('span', { className: 'tl-range-live' }, win.live ? 'live, up to the latest activity' : 'earlier window, not live')), - h('div', { className: 'tl-body' }, ...notesOf(snap).map((note, i) => h('p', { key: `n${i}`, className: note.err ? 'tl-note err' : 'tl-note', role: note.err ? 'alert' : undefined }, note.text)), body), + h('div', { className: 'tl-body' }, ...notesOf(snap).map((note, i) => h('p', { key: `n${i}`, className: note.err ? 'tl-note err' : 'tl-note', role: note.err ? 'alert' : undefined }, note.text)), copy.status === 'error' && h('p', { className: 'tl-note err', role: 'alert' }, `Could not copy the report: ${copy.error}`), body), h('div', { className: 'tl-foot' }, h('span', null, 'Delivered: in the other Bot\'s log, not read.'), h('span', null, 'Model time: turn time outside tools.')))) @@ -675,7 +707,7 @@ window.__ModuleLoader__.load({ return off } return { - name: 'flybot-timeline', inject, apply, createStore, checkWindow, ENDPOINT, VERSION, POLL_MS, WINDOWS, CSS, + name: 'flybot-timeline', inject, apply, createStore, checkWindow, ENDPOINT, DIGEST_ENDPOINT, VERSION, POLL_MS, WINDOWS, CSS, copyText, safe, fmtDur, fmtClock, fmtRange, makeScale, niceTicks, layoutSwimlanes, layoutMap, waitRows, utilizationRows, createPanelState, Swimlanes, CommMap, Utilization, TimelinePanel, makeSection, notesOf, } diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js new file mode 100644 index 000000000..8fa15e875 --- /dev/null +++ b/plugins/timeline/src/digest.js @@ -0,0 +1,129 @@ +// A team run report (#357/#401, #582): the window's per-Bot numbers and the team's rollup, as one +// short Markdown digest a person reads in a standup or a routine posts into a chat. Pure: a window +// in, text out. It reads what the window carries and quietly skips a field an older answer does not +// have (tools, waits), so it stays useful as the timeline grows. Read-only: nothing is written and +// no Session is touched. +const K = 1_000 +const M = 1_000_000 +const BUSY_MIN = 0.9 +const ERROR_RATE_MIN = 0.5 +const ERROR_TURNS_MIN = 3 +const TOP_TOOLS = 5 + +/** A count a person reads at a glance: `840`, `12.3k`, `1.2M`. */ +export function formatCount(value) { + const n = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0 + if (n >= M) return `${(n / M).toFixed(1)}M` + if (n >= K) return `${(n / K).toFixed(1)}k` + return String(n) +} + +/** A duration a person reads at a glance: `40s`, `12m`, `1h05m`. */ +export function formatMs(ms) { + const value = Number.isFinite(ms) ? Math.max(0, Math.round(ms)) : 0 + if (value < 60_000) return `${Math.round(value / 1000)}s` + if (value < 3_600_000) return `${Math.round(value / 60_000)}m` + const minutes = Math.round((value % 3_600_000) / 60_000) + return `${Math.floor(value / 3_600_000)}h${String(minutes).padStart(2, '0')}m` +} + +const percent = (part) => `${Math.round(part * 100)}%` +const pad = n => String(n).padStart(2, '0') +// The reader's own clock, the hours the panel header prints (`fmtClock` in client.js): a report +// copied from the panel must not name a different time than the header above the button. +const clock = (ms) => { + const at = new Date(ms) + return Number.isFinite(at.getTime()) ? `${pad(at.getHours())}:${pad(at.getMinutes())}` : '' +} + +/** + * `window` is a `timeline/window` answer. Resolves `{ title, range, live, bots, team, markdown, + * line }`. `bots` is the per-Bot view in the order the window lists it (the Main Bot first, as the + * panel draws it), `team` the rollup, `markdown` the report, `line` a one-line summary a reminder + * can post. + */ +export function buildDigest(window = {}, { title = 'Team report', now = Date.now() } = {}) { + const lanes = new Map((window.lanes ?? []).map(lane => [lane.id, lane])) + const usageBy = new Map((window.usage ?? []).map(use => [use.lane, use])) + const toolUseBy = new Map((window.toolUse ?? []).map(use => [use.lane, use.tools ?? []])) + const waitingBy = new Map() + for (const edge of window.edges ?? []) { + if (edge?.unanswered > 0 && typeof edge.from === 'string') waitingBy.set(edge.from, (waitingBy.get(edge.from) ?? 0) + edge.unanswered) + } + + const bots = (window.utilization ?? []).map((use) => { + const lane = lanes.get(use.lane) + const usage = usageBy.get(use.lane) ?? {} + const turns = use.turns ?? 0 + const errors = use.errors ?? 0 + const waiting = waitingBy.get(use.lane) ?? 0 + const errorRate = turns > 0 ? errors / turns : 0 + const busyShare = use.windowMs > 0 ? use.busyMs / use.windowMs : 0 + const flags = [] + if (turns >= ERROR_TURNS_MIN && errorRate >= ERROR_RATE_MIN) flags.push(`${errors} of ${turns} turns failed`) + if (turns > 0 && busyShare >= BUSY_MIN) flags.push(`busy ${percent(busyShare)} of the window`) + if (turns === 0) flags.push('no turns') + if (waiting > 0) flags.push(`${waiting} request(s) unanswered`) + return { + id: use.lane, + name: lane?.name ?? use.lane, + main: lane?.main === true, + turns, errors, errorRate, busyShare, + running: use.running === true, + tokens: { input: usage.input ?? 0, output: usage.output ?? 0, cacheRead: usage.cacheRead ?? 0, cacheWrite: usage.cacheWrite ?? 0, calls: usage.calls ?? 0 }, + tools: (toolUseBy.get(use.lane) ?? []).slice(0, TOP_TOOLS), + waiting, flags, + } + }) + + // The longest still-open request: `stuck` already carries the wait, worst first (#469); an older + // host has none, so the waiting exchanges are measured against `now` instead. + const openWaits = window.stuck ?? (window.exchanges ?? []) + .filter(arrow => arrow.waiting === true) + .map(arrow => ({ id: arrow.id, from: arrow.from, to: arrow.to, waitedMs: Math.max(0, now - (arrow.t ?? now)) })) + const longest = openWaits.slice().sort((a, b) => (b.waitedMs ?? 0) - (a.waitedMs ?? 0))[0] + + const sumTokens = (key) => bots.reduce((total, bot) => total + bot.tokens[key], 0) + const team = { + turns: bots.reduce((sum, bot) => sum + bot.turns, 0), + errors: bots.reduce((sum, bot) => sum + bot.errors, 0), + tokens: { input: sumTokens('input'), output: sumTokens('output'), cacheRead: sumTokens('cacheRead'), cacheWrite: sumTokens('cacheWrite'), calls: sumTokens('calls') }, + longestWait: longest === undefined ? null : { id: longest.id, from: longest.from, to: longest.to, waitedMs: longest.waitedMs ?? 0 }, + unhealthy: bots.filter(bot => bot.turns >= ERROR_TURNS_MIN && bot.errorRate >= ERROR_RATE_MIN).map(bot => bot.id), + } + + const from = clock(window.from) + const to = clock(window.to) + const range = from && to ? `${from}–${to}` : 'the window' + const lines = [`# ${title}`, `Window: ${range}${window.live === true ? ' · live' : ''}`] + + if (bots.length === 0) { + lines.push('', 'No activity in the window.') + } else { + for (const bot of bots) { + lines.push('', `## ${bot.name}${bot.main ? ' (main)' : ''}${bot.running ? ' · running' : ''}`) + if (bot.turns > 0) { + const failed = bot.errors > 0 ? `, ${bot.errors} failed (${percent(bot.errorRate)})` : '' + lines.push(`- ${bot.turns} turns${failed}`) + lines.push(`- busy ${percent(bot.busyShare)} of the window`) + lines.push(`- tokens: ${formatCount(bot.tokens.input)} in / ${formatCount(bot.tokens.output)} out / ${formatCount(bot.tokens.cacheRead)} cached`) + } else { + lines.push('- no turns in the window') + } + if (bot.waiting > 0) lines.push(`- ${bot.waiting} request(s) unanswered`) + if (bot.tools.length > 0) lines.push(`- tools: ${bot.tools.map(tool => `${tool.name} ×${tool.calls}${tool.failed > 0 ? ` (${tool.failed} failed)` : ''}`).join(', ')}`) + const look = bot.flags.filter(flag => flag !== 'no turns') + if (look.length > 0) lines.push(`- look: ${look.join('; ')}`) + } + } + + lines.push('', '## Team', `- ${team.turns} turns, ${team.errors} failed`) + lines.push(`- tokens: ${formatCount(team.tokens.input)} in / ${formatCount(team.tokens.output)} out (${team.tokens.calls} calls)`) + if (team.longestWait !== null) lines.push(`- longest wait: ${team.longestWait.from} → ${team.longestWait.to}, ${formatMs(team.longestWait.waitedMs)}`) + if (team.unhealthy.length > 0) lines.push(`- needs a look: ${team.unhealthy.join(', ')}`) + + const line = team.turns === 0 + ? `No Bot activity in ${range}.` + : `${team.turns} turns, ${team.errors} failed, ${formatCount(team.tokens.input)} tokens in over ${range}.` + return { title, range, live: window.live === true, bots, team, markdown: lines.join('\n'), line } +} diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 6d04232bf..3dbcd975a 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -5,11 +5,13 @@ import { buildWindow, clampWindow } from './window.js' import { createExporter, otlpPayload, spansFrom } from './otel.js' import { buildHealth } from './health.js' import { createAlertTracker } from './alerts.js' +import { buildDigest } from './digest.js' export const ENDPOINT = 'timeline/window' export const EXPORT_ENDPOINT = 'timeline/export' export const HEALTH_ENDPOINT = 'timeline/health' export const ALERTS_ENDPOINT = 'timeline/alerts' +export const DIGEST_ENDPOINT = 'timeline/digest' export function createTimeline(adapter, { dir, persist = true, now = Date.now, exporter, ...limits } = {}) { let trace @@ -61,14 +63,18 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e const alerts = createAlertTracker() const alertView = (body = {}) => alerts.fold(window(body)) const offAlerts = adapter.handle(ALERTS_ENDPOINT, alertView) + // The team run report (#582): the same window, as short Markdown a person can paste. Read-only. + const digest = (body = {}) => buildDigest(window(body), { ...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.trim().slice(0, 80) } : {}), now: now() }) + const offDigest = adapter.handle(DIGEST_ENDPOINT, digest) return { - trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, + trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, digest, dispose() { if (exportTimer !== undefined) clearInterval(exportTimer) offEndpoint() offExport() offHealth() offAlerts() + offDigest() off() trace.close() }, diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js new file mode 100644 index 000000000..a036ef91e --- /dev/null +++ b/plugins/timeline/test/digest.test.js @@ -0,0 +1,137 @@ +// The report prints the reader's local clock (#582), so this file runs in a fixed zone ahead of +// UTC: a formatter that quietly prints UTC cannot pass here just because the CI runner is on UTC. +process.env.TZ = 'Asia/Shanghai' + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildDigest, formatCount, formatMs } from '../src/digest.js' +import { createTimeline, DIGEST_ENDPOINT } from '../src/timeline.js' +import { mockAdapter } from './mock-adapter.js' + +const make = (extra = {}) => ({ + version: 1, now: 100_000, from: 0, to: 100_000, windowMs: 100_000, live: true, + lanes: [{ id: 'user', kind: 'user', name: 'You' }, { id: 'a', kind: 'bot', name: 'Chief', main: true }, { id: 'b', kind: 'bot', name: 'Writer' }], + utilization: [ + { lane: 'a', windowMs: 100_000, busyMs: 62_000, turns: 4, errors: 1, running: false }, + { lane: 'b', windowMs: 100_000, busyMs: 0, turns: 0, errors: 0, running: false }, + ], + usage: [{ lane: 'a', calls: 4, input: 12_345, output: 1_234, cacheRead: 8_000, cacheWrite: 0 }], + toolUse: [{ lane: 'a', tools: [{ name: 'web_search', calls: 6, failed: 1 }, { name: 'read', calls: 2, failed: 0 }] }], + edges: [{ from: 'b', to: 'a', unanswered: 1 }], + stuck: [{ id: 'x9', from: 'b', to: 'a', role: 'request', waitedMs: 720_000 }], + exchanges: [{ id: 'x9', from: 'b', to: 'a', waiting: true, t: 100_000 - 720_000 }], + ...extra, +}) + +test('formatCount and formatMs read at a glance', () => { + assert.equal(formatCount(840), '840') + assert.equal(formatCount(12_345), '12.3k') + assert.equal(formatCount(1_234_567), '1.2M') + assert.equal(formatCount(-3), '0') + assert.equal(formatCount(undefined), '0') + assert.equal(formatMs(40_000), '40s') + assert.equal(formatMs(720_000), '12m') + assert.equal(formatMs(3_900_000), '1h05m') + assert.equal(formatMs(undefined), '0s') +}) + +test('#582: per-Bot sections, the team rollup and the longest wait', () => { + const digest = buildDigest(make(), { now: 100_000 }) + assert.equal(digest.bots[0].name, 'Chief') + assert.equal(digest.bots[0].main, true) + assert.equal(digest.bots[0].turns, 4) + assert.equal(digest.bots[0].waiting, 0) + assert.equal(digest.bots[1].waiting, 1) + assert.deepEqual(digest.bots[0].tools.map(tool => tool.name), ['web_search', 'read']) + assert.equal(digest.team.turns, 4) + assert.equal(digest.team.errors, 1) + assert.equal(digest.team.tokens.input, 12_345) + assert.deepEqual(digest.team.longestWait, { id: 'x9', from: 'b', to: 'a', waitedMs: 720_000 }) + assert.match(digest.markdown, /^# Team report\nWindow: 08:00–08:01 · live/) + assert.match(digest.markdown, /## Chief \(main\)\n- 4 turns, 1 failed \(25%\)/) + assert.match(digest.markdown, /- busy 62% of the window/) + assert.match(digest.markdown, /- tokens: 12.3k in \/ 1.2k out \/ 8.0k cached/) + assert.match(digest.markdown, /- 1 request\(s\) unanswered/) + assert.match(digest.markdown, /- tools: web_search ×6 \(1 failed\), read ×2/) + assert.match(digest.markdown, /## Writer\n- no turns in the window/) + assert.match(digest.markdown, /## Team\n- 4 turns, 1 failed/) + assert.match(digest.markdown, /- longest wait: b → a, 12m/) + assert.match(digest.line, /4 turns, 1 failed, 12.3k tokens in over 08:00–08:01\./) +}) + +// The endpoint must work before the richer fields land (#582): a window from an older host carries +// no `stuck` and no `toolUse`, and the report is shorter rather than broken. +test('#582: a window without the newer fields still builds, and says less', () => { + const digest = buildDigest(make({ toolUse: undefined, stuck: undefined, exchanges: [] }), { now: 100_000 }) + assert.doesNotMatch(digest.markdown, /tools:/) + assert.doesNotMatch(digest.markdown, /longest wait/) + assert.equal(digest.team.longestWait, null) + // Without `stuck` the waiting exchange is measured against `now` instead of being dropped. + const old = buildDigest(make({ stuck: undefined }), { now: 820_000 }) + assert.equal(old.team.longestWait.waitedMs, 1_440_000) +}) + +test('#582: an empty window is a short honest report', () => { + const digest = buildDigest({}) + assert.equal(digest.bots.length, 0) + assert.match(digest.markdown, /No activity in the window\./) + assert.match(digest.line, /No Bot activity/) + assert.equal(digest.markdown.includes('undefined'), false) +}) + +test('#582: a Bot that fails most turns is named as needing a look', () => { + const digest = buildDigest(make({ utilization: [{ lane: 'a', windowMs: 100_000, busyMs: 1000, turns: 4, errors: 3, running: false }] }), { now: 100_000 }) + assert.deepEqual(digest.team.unhealthy, ['a']) + assert.match(digest.markdown, /- needs a look: a/) + assert.match(digest.markdown, /- look: 3 of 4 turns failed/) +}) + +// The rollup is the sum of the per-Bot numbers, never a separate count: a report a person reads +// must not disagree with the lines above it. +test('#582: the team rollup equals the Bot sections', () => { + const digest = buildDigest(make({ + utilization: [ + { lane: 'a', windowMs: 100_000, busyMs: 10_000, turns: 2, errors: 0 }, + { lane: 'b', windowMs: 100_000, busyMs: 5_000, turns: 3, errors: 2 }, + ], + usage: [{ lane: 'a', calls: 2, input: 10, output: 4, cacheRead: 1 }, { lane: 'b', calls: 3, input: 20, output: 5, cacheRead: 2 }], + }), { now: 100_000 }) + for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'calls']) { + assert.equal(digest.team.tokens[key], digest.bots.reduce((sum, bot) => sum + bot.tokens[key], 0), key) + } + assert.equal(digest.team.turns, digest.bots.reduce((sum, bot) => sum + bot.turns, 0)) + assert.equal(digest.team.errors, digest.bots.reduce((sum, bot) => sum + bot.errors, 0)) +}) + +// A report copied at 17:05 says 17:05 — the hour the panel header above the button shows, not the +// UTC hour, and with no zone the reader has to guess (#582). +test('#582: the report names local hours, like the panel header', () => { + const from = Date.UTC(2026, 9, 22, 9, 5) + const to = Date.UTC(2026, 9, 22, 9, 20) + const digest = buildDigest(make({ from, to, live: false }), { now: to }) + assert.equal(digest.range, '17:05–17:20') + assert.match(digest.markdown, /^# Team report\nWindow: 17:05–17:20\n/) +}) + +// The endpoint is the feature, not the pure builder: the suite stayed green with +// `adapter.handle(DIGEST_ENDPOINT, digest)` and its `offDigest()` deleted (#582), so this goes +// through the real host, the way test/host.test.js does for `timeline/window`. +test('#582: the endpoint is registered read-only and removed on dispose', () => { + const adapter = mockAdapter() + const timeline = createTimeline(adapter, { persist: false, now: () => 100_000 }) + try { + assert.equal(DIGEST_ENDPOINT, 'timeline/digest') + assert.ok(adapter.endpoints.has(DIGEST_ENDPOINT), 'createTimeline registers timeline/digest') + const report = adapter.endpoints.get(DIGEST_ENDPOINT)({ windowMs: 300_000, title: ' Standup ' }) + assert.equal(report.title, 'Standup') + assert.match(report.markdown, /^# Standup\nWindow: \d\d:\d\d–\d\d:\d\d · live\n/) + // The host hangs the endpoint on the real window: every Bot it knows, with no turns yet. + assert.match(report.markdown, /## Chief \(main\)\n- no turns in the window/) + assert.match(report.markdown, /## Team\n- 0 turns, 0 failed/) + // Read-only: answering again changes nothing and keeps the default title. + assert.equal(adapter.endpoints.get(DIGEST_ENDPOINT)({}).title, 'Team report') + } finally { + timeline.dispose() + } + assert.equal(adapter.endpoints.has(DIGEST_ENDPOINT), false, 'dispose removes the endpoint') +}) diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index 98ecd8cc2..c0813fdf7 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -488,3 +488,66 @@ test('a turn bar\'s tooltip names the model the turn ran on (#366)', () => { assert.match(bar('a#1').title, /^Chief: .* · deepseek-v4-pro$/) assert.doesNotMatch(bar('a#2').title, /deepseek/) }) + +// ---- the run report (#582) -------------------------------------------------------------- +// The report is fetched on demand, never by the poller, and the clipboard is the browser's: the test +// installs one, as a page would have, and reads what the button copied. +const withClipboard = (fn) => { + const written = [] + const had = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: { clipboard: { writeText: async (text) => { written.push(text) } } }, configurable: true, writable: true }) + return Promise.resolve(fn(written)).finally(() => { + if (had) Object.defineProperty(globalThis, 'navigator', had) + else delete globalThis.navigator + }) +} + +test('#582: the data layer asks for the report at the window the panel shows', async () => { + const asked = [] + const flybot = { call: async (endpoint, body) => { asked.push([endpoint, body]); return { markdown: '# Team report', line: '4 turns' } } } + const store = client.createStore(flybot, { windowMs: 300_000 }) + const report = await store.digest() + assert.deepEqual(asked, [['timeline/digest', { windowMs: 300_000 }]]) + assert.equal(report.markdown, '# Team report') + assert.equal(report.line, '4 turns') + store.setWindow({ windowMs: 60_000 }) + await store.digest({ title: ' Standup ' }) + assert.deepEqual(asked.at(-1), ['timeline/digest', { windowMs: 60_000, title: 'Standup' }]) + // A host that answers nothing usable is an error, not an empty report. + const silent = client.createStore({ call: async () => ({}) }, {}) + await assert.rejects(() => silent.digest(), /no report/) + store.dispose() + silent.dispose() +}) + +test('#582: Copy report copies the Markdown and says so', async () => { + await withClipboard(async (written) => { + const api = fakeApi(okState()) + api.digest = async () => ({ markdown: '# Team report\n\n## Chief\n- 4 turns' }) + const view = mount(client.TimelinePanel, { api, onClose() {} }) + assert.ok(button(view.tree, 'Copy report')) + button(view.tree, 'Copy report').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.deepEqual(written, ['# Team report\n\n## Chief\n- 4 turns']) + assert.ok(button(view.tree, 'Copied')) + view.unmount() + }) +}) + +test('#582: a refused clipboard is reported, not swallowed', async () => { + const api = fakeApi(okState()) + api.digest = async () => ({ markdown: '# Team report' }) + const had = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + Object.defineProperty(globalThis, 'navigator', { value: {}, configurable: true, writable: true }) + try { + const view = mount(client.TimelinePanel, { api, onClose() {} }) + button(view.tree, 'Copy report').props.onClick() + await new Promise(resolve => setTimeout(resolve, 0)) + assert.match(textOf(findAll(view.tree, node => node.props.role === 'alert')[0]), /Could not copy the report: this page has no clipboard/) + assert.ok(button(view.tree, 'Copy report')) // the button stays available + view.unmount() + } finally { + if (had) Object.defineProperty(globalThis, 'navigator', had) + else delete globalThis.navigator + } +})