Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/582-timeline-digest.added.md
Original file line number Diff line number Diff line change
@@ -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)).
6 changes: 4 additions & 2 deletions docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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

Expand All @@ -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

Expand Down
36 changes: 34 additions & 2 deletions plugins/timeline/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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
Expand Down Expand Up @@ -54,7 +55,7 @@
let generation = 0
let stopWatching
const listeners = new Set()
const emit = () => { for (const fn of [...listeners]) fn(state) }

Check warning on line 58 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
const set = patch => { state = { ...state, ...patch }; emit() }

const schedule = (ms) => {
Expand Down Expand Up @@ -124,13 +125,32 @@
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)

Check warning on line 153 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

eslint(no-control-regex)

Unexpected control characters
// A turn's model as the tooltip names it: the model id of `provider/model`, as Bot settings fall back to.
const modelName = ref => safe(String(ref ?? '').split('/').slice(1).join('/') || ref, 80)
const pad = n => String(n).padStart(2, '0')
Expand Down Expand Up @@ -234,7 +254,7 @@
// What a lane is, under its name: plain Bots say nothing.
const subOf = (lane, name) => {
if (lane.kind === 'friend') return name.includes(' · ') ? name.split(' · ').slice(1).join(' · ') : 'friend'
if (/^Removed /.test(name)) return ''

Check warning on line 257 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(prefer-string-starts-ends-with)

Prefer String#startsWith over a regex with a caret.
if (lane.kind === 'room') return lane.removed ? 'removed group' : 'group'
return lane.main ? 'main' : lane.removed ? 'removed' : ''
}
Expand Down Expand Up @@ -597,6 +617,17 @@
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)
Expand Down Expand Up @@ -624,12 +655,13 @@
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.'))))
Expand All @@ -639,7 +671,7 @@
function createPanelState() {
let open = false
const listeners = new Set()
const set = (value) => { if (open !== value) { open = value; for (const fn of [...listeners]) fn(open) } }

Check warning on line 674 in plugins/timeline/client.js

View workflow job for this annotation

GitHub Actions / plugins (timeline)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
return { get open() { return open }, show: () => set(true), hide: () => set(false), toggle: () => set(!open), subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) } }
}
function makeSection(api) {
Expand Down Expand Up @@ -675,7 +707,7 @@
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,
}
Expand Down
129 changes: 129 additions & 0 deletions plugins/timeline/src/digest.js
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading