From 9afc5c7d1fbc9e09ad02577db27b4772f0a7e40d Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:36:21 +0800 Subject: [PATCH] Timeline: a team-health endpoint (#357) timeline/health reads the same window and returns per Bot its turns, error rate, busy share, unanswered requests and short flags (errors, busy, idle, waiting), plus a team ok. Read-only, no configuration; the action view over the numbers the panel already draws. --- changelog.d/timeline-health.added.md | 1 + docs/subsystems/timeline.md | 2 ++ plugins/timeline/src/health.js | 47 ++++++++++++++++++++++++++++ plugins/timeline/src/timeline.js | 8 ++++- plugins/timeline/test/health.test.js | 42 +++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 changelog.d/timeline-health.added.md create mode 100644 plugins/timeline/src/health.js create mode 100644 plugins/timeline/test/health.test.js diff --git a/changelog.d/timeline-health.added.md b/changelog.d/timeline-health.added.md new file mode 100644 index 00000000..91c6ec66 --- /dev/null +++ b/changelog.d/timeline-health.added.md @@ -0,0 +1 @@ +The Timeline plugin can now report team health: `timeline/health` reads the same window and returns, per Bot, its turns, error rate, how busy it was, how many of its requests are unanswered, and short flags (`errors`, `busy`, `idle`, `waiting`), plus a single `ok` for the team. It is read-only and needs no configuration. diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index d7987478..1e4f6644 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -27,6 +27,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model | `plugins/timeline/src/recorder.js` | Feed events to compact trace records; rebuilds every field from a strict alphabet. | | `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) and per-Bot outcomes and recent failures (#471). | +| `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. | | `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/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. | @@ -36,6 +37,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model - `plugins/timeline/index.js` — loaded as `@flybot/timeline`. - Endpoint `timeline/window` (wire name `flybot/timeline/window`), registered `{ read: true }` so a read-only host still answers. Body `{ windowMs?, to? }`; window 30 s to 24 h, default 15 min. - 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`. ## Invariants diff --git a/plugins/timeline/src/health.js b/plugins/timeline/src/health.js new file mode 100644 index 00000000..e1f3fedf --- /dev/null +++ b/plugins/timeline/src/health.js @@ -0,0 +1,47 @@ +// A team-health read model (#357): the window's raw numbers turned into per-Bot signals and a +// short set of flags a person can act on, so observability becomes a decision instead of a chart. +// Pure: a window in, a plain object out. It reads only what the window already carries (turns, +// errors, utilization, waits), so it records nothing new and never touches a Session. +const ERROR_RATE_MIN = 0.5 +const ERROR_TURNS_MIN = 3 +const BUSY_SHARE = 0.9 + +const share = (part, whole) => (whole > 0 ? part / whole : 0) +const round = value => Math.round(value * 1000) / 1000 + +/** + * `window` is a `timeline/window` answer. Resolves `{ version, now, windowMs, live, ok, bots, + * unhealthy }`. Each Bot carries its turns, errors, error rate, share of the window it was busy, + * whether it is running now, how many of its requests are unanswered, and flags + * (`errors` / `busy` / `idle` / `waiting`). `ok` is false when any Bot has an `errors` or + * `waiting` flag; `unhealthy` names them. + */ +export function buildHealth(window = {}) { + const lanes = new Map((window.lanes ?? []).map(lane => [lane.id, lane])) + // Unanswered requests per sender Bot: where a Bot's work is waiting on someone else. + const waitingOn = new Map() + for (const edge of window.edges ?? []) { + if (!(edge?.unanswered > 0) || typeof edge.from !== 'string') continue + waitingOn.set(edge.from, (waitingOn.get(edge.from) ?? 0) + edge.unanswered) + } + const bots = (window.utilization ?? []).map((use) => { + const lane = lanes.get(use.lane) + const errorRate = share(use.errors, use.turns) + const busyShare = share(use.busyMs, use.windowMs) + const waiting = waitingOn.get(use.lane) ?? 0 + const flags = [] + if (use.turns >= ERROR_TURNS_MIN && errorRate >= ERROR_RATE_MIN) flags.push({ code: 'errors', detail: `${use.errors} of ${use.turns} turns failed` }) + if (use.turns > 0 && busyShare >= BUSY_SHARE) flags.push({ code: 'busy', detail: `busy ${Math.round(busyShare * 100)}% of the window` }) + if (use.turns === 0) flags.push({ code: 'idle', detail: 'no turns in the window' }) + if (waiting > 0) flags.push({ code: 'waiting', detail: `${waiting} request(s) unanswered` }) + return { + id: use.lane, + name: lane?.name ?? use.lane, + ...(lane?.main === true ? { main: true } : {}), + turns: use.turns, errors: use.errors, errorRate: round(errorRate), busyShare: round(busyShare), + running: use.running === true, waiting, flags, + } + }) + const unhealthy = bots.filter(bot => bot.flags.some(flag => flag.code === 'errors' || flag.code === 'waiting')).map(bot => bot.id) + return { version: 1, now: window.now ?? null, windowMs: window.windowMs ?? null, live: window.live === true, bots, unhealthy, ok: unhealthy.length === 0 } +} diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 35cb2fe8..e3c50824 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -3,9 +3,11 @@ import { createRecorder } from './recorder.js' import { openTrace, TraceLocked } from './store.js' import { buildWindow, clampWindow } from './window.js' import { createExporter, otlpPayload, spansFrom } from './otel.js' +import { buildHealth } from './health.js' export const ENDPOINT = 'timeline/window' export const EXPORT_ENDPOINT = 'timeline/export' +export const HEALTH_ENDPOINT = 'timeline/health' export function createTimeline(adapter, { dir, persist = true, now = Date.now, exporter, ...limits } = {}) { let trace @@ -49,12 +51,16 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e // sends another home's names by accident. const exportWindow = (body = {}) => otlpPayload(spansFrom(trace.records(), { names: body?.names === true ? trace.names() : {} }), { serviceName: body?.serviceName }) const offExport = adapter.handle(EXPORT_ENDPOINT, exportWindow) + // Team health (#357): the same window, reduced to per-Bot signals and flags. Read-only. + const health = (body = {}) => buildHealth(window(body)) + const offHealth = adapter.handle(HEALTH_ENDPOINT, health) return { - trace, recorder, window, exportNow, exportWindow, + trace, recorder, window, exportNow, exportWindow, health, dispose() { if (exportTimer !== undefined) clearInterval(exportTimer) offEndpoint() offExport() + offHealth() off() trace.close() }, diff --git a/plugins/timeline/test/health.test.js b/plugins/timeline/test/health.test.js new file mode 100644 index 00000000..9452018b --- /dev/null +++ b/plugins/timeline/test/health.test.js @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { buildHealth } from '../src/health.js' + +const make = (extra = {}) => ({ + version: 1, now: 1000, windowMs: 1000, live: true, + lanes: [{ id: 'user', kind: 'user', name: 'You' }, { id: 'a', kind: 'bot', name: 'Chief', main: true }, { id: 'b', kind: 'bot', name: 'Writer' }], + utilization: [], edges: [], ...extra, +}) + +test('health: a Bot with many failed turns is flagged, and the team is unhealthy', () => { + const health = buildHealth(make({ + utilization: [ + { lane: 'a', windowMs: 1000, busyMs: 900, turns: 4, errors: 3, running: false }, + { lane: 'b', windowMs: 1000, busyMs: 100, turns: 2, errors: 0, running: true }, + ], + edges: [{ from: 'b', to: 'a', requests: 1, replies: 0, unanswered: 1 }], + })) + const a = health.bots.find(bot => bot.id === 'a') + assert.deepEqual(a.flags.map(flag => flag.code), ['errors', 'busy']) + assert.equal(a.errorRate, 0.75) + assert.equal(a.name, 'Chief') + assert.equal(a.main, true) + const b = health.bots.find(bot => bot.id === 'b') + assert.deepEqual(b.flags.map(flag => flag.code), ['waiting']) + assert.equal(b.waiting, 1) + assert.equal(b.running, true) + assert.equal(health.ok, false) + assert.deepEqual(health.unhealthy, ['a', 'b']) +}) + +test('health: an idle Bot is flagged but does not make the team unhealthy', () => { + const health = buildHealth(make({ utilization: [{ lane: 'b', windowMs: 1000, busyMs: 0, turns: 0, errors: 0, running: false }] })) + const b = health.bots.find(bot => bot.id === 'b') + assert.deepEqual(b.flags, [{ code: 'idle', detail: 'no turns in the window' }]) + assert.equal(health.ok, true) + assert.deepEqual(health.unhealthy, []) +}) + +test('health: an empty window is ok and has no Bots', () => { + assert.deepEqual(buildHealth({}), { version: 1, now: null, windowMs: null, live: false, bots: [], unhealthy: [], ok: true }) +})