| subsystem | timeline | |
|---|---|---|
| owned_paths |
|
Where time goes across the local Bot team (#241). The plugin records Bot turns, tool calls and Bot-to-Bot deliveries from the core's activity feed, keeps a capped trace on disk, and answers the panel's poll on flybot/timeline/window. It lives entirely under plugins/timeline/.
Everything in plugins/timeline/: the recorder, the trace file, the read model behind the endpoint and the browser entry (client.js: data layer and the panel).
- The activity feed (
flybot.on('turn.start' | …)) andflybot.api.handle→ plugin-runtime. - DSH session events: never read here, never patched.
- Install scripts and doctor → install.
| File | Role |
|---|---|
plugins/timeline/index.js |
Plugin entry (flybot-timeline, injects flybot). |
plugins/timeline/src/adapter.js |
The only file that knows the ctx.flybot shape. |
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), 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). A duration rounds once and carries into the next unit (#761), so the text says 1m and 2h00m, never 60s, 60m or 1h60m. |
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, 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. |
plugins/timeline/index.js— loaded as@flybot/timeline.- Endpoint
timeline/window(wire nameflybot/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 setsnames: true(#483);serviceNameoverrides 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 teamok; the action view over the numbers the panel draws. Body{ windowMs?, to? }liketimeline/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) andstuck(a handoff waited past the threshold) — answered as{ open, resolved, changed, stale }. Body{ windowMs?, to? }liketimeline/window. A condition opens ONE alert with itssinceand 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,toolUseandstuckwhen present, and nothing is invented when they are absent (buildDigest/formatCount/formatMsinsrc/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.
- Timeline code stays inside
plugins/timeline/. Events come only fromctx.flybot(the activity feed); it never subscribes to DSHsession/event, never editsds-bot/orcore/src/. Runtime sources import only this package, Node builtins ords-bot(#153, enforced byscripts/lint-plugins.mjs). - Metadata only. A trace record holds ids, times, outcomes, tool names, message lengths, cleaned Bot/group names and the model a turn ran on (
te.md,provider/modelfrom the feed'sturn.end.model, #366). Never message text, tool arguments or tool output; the feed does not carry them and the recorder would not copy them. Tests assert that a secret in any of those never reaches the trace file. - Untrusted strings are rebuilt at the plugin's own boundary (
sanitize.js) on the way in and again on the way out (window.js): ids and codes from strict patterns, labels stripped of control and bidi characters, markup brackets anduntrustedfence markers (also when split or spaced out), and length-capped. An error code is an upper-case word orUNKNOWN. - Write discipline matches
dev/interop/journal.mjs: one writer (writer.lock, refused again inside one process), a segment is only replaced whole (tmp, fsync, rename, directory fsync). The trace is capped at 3 segments of 2000 records (about 3000 spans) and 24 hours, whichever comes first. A second dsh on the same directory, or an unwritable directory, records in memory only and says so in the response (trace.persisting). - Honest numbers. "Delivered" is when a message reached the recipient's log; there are no read stamps. "Idle" means no running turn; a Bot waiting on a model call or a slow tool is busy. The health flags follow that:
busyis a share of the window busy, not a count of turns, so a Bot in one long turn that began before the window isbusy, andidleneeds no overlapping turn and nothing running; theerrorsrate divides by the larger of the turns that began in the window and the failed turns, so it never reads a failure count above its turn count. "Model" time is turn time outside tool calls (queueing and host overhead included), not a measured model latency. A turn an earlier run left open is closed at the last record before the restart and markedlost. "You asked" latency is the length of the turn it started. - A friend's Bot is a lane of its own, never a Bot of the local team (#248): it shows as a
friendlane (its chat's name, else its handle) with messages only. The local side of a friend chat runs no model, so its transport turns are not recorded and it has no utilization row; your line to a friend waits until the friend's next line back (role: 'remote'), and a friend's request into one of your Bots is answered by that Bot's turn as a typed line is. Lanes never show a raw session id: a gone Bot or group with no remembered name isRemoved Bot/Removed group. Group rooms are the room Session's own turn (the round), with the members' hidden group turns inside it. - The browser poller runs only while a panel holds it open, one request in flight, backing off on errors (4 s to 30 s) and keeping the last good window; a reply of another version is an error state, never drawn from. A request a window change superseded (a new one replaced it) does not clear the in-flight guard of the one that replaced it, so coming back to the tab never doubles the poll. A hidden page loads once (a panel opened in a background tab is never left on Loading…, #259), then stops polling; becoming visible refreshes at once.
- The panel draws text only: every string is a React text child or an SVG text node, never markup, and is cleaned again in the browser (
safe). It polls only while open (and refreshes at once when the tab or window comes back, #259, #271), shows the last good data under an error, and says in a note when the trace is shorter than the window, truncated, memory-only or looking back. Its one-line footnote repeats the honesty rules of invariant 5 (delivered is not read, busy is not idle, model time is not a measured latency). The dialog shrinks to its content and hangs from the top (switching tabs moves nothing above the body), the Live control is always shown (pressed while following the latest activity, a call to action when looking back), lanes name what they are under the name (main,group,friend,removed) with no icon prefix, and the map puts each name beside or under its node, clipped with the full name in a tooltip (#249). A turn bar's tooltip ends with the model the turn ran on (its model id,· deepseek-v4-pro); a running turn, or one recorded before #366, names none (#366). A friend's Bot is named as the sidebar names it (UiA1415's Chief,Helper · UiB1415's Bot, from the feed'sremote.name/ partyname), so two Bots called Helper stay apart; a wait label that would run past the plot is right-aligned, and one with no room is left to the arrow's tooltip (#255). The window's range is always in view under the header (Oct 18 · 13:20:05 – 13:35:05 (15 min), dates at both ends across midnight, and whether it is live), and the footnote is two short items, not one line (#279). - Opt-in (#281):
applywithoutconfig.enabled === truesubscribes to nothing, writes nothing and registers no endpoint; the browser entry draws no sidebar row unlessconfig.sidebarSection === trueand its poller only runs while a panel is open. A test asserts the host does nothing by default. Docs and the install doctor's default commands do not present it as part of a standard install. - The endpoint reads only. It does not start, stop, send or change anything.
- OTLP export (#483) is opt-in and off by default: without
config.exporternothing leaves the host. Withexporter: "otlp"the plugin POSTs OTLP/HTTP JSON tootlpUrl; with"json"it appendsdir/export.jsonl(capped at 8 MiB, then it stops and warns).otlpUrlmust behttps, orhttpon localhost only (the request may carry an auth header). Only CLOSED spans are exported: a turn needs itsteand a tool call itske, so an open turn is carried between flushes and exported when it ends, never as a finished success. The exporter reads only the same compact records the panel does (invariant 2 still holds: no message text, arguments or output), maps turns toinvoke_agentspans and tool calls toexecute_toolspans under them, and carries every id through the same deterministic hash. Bot and room NAMES are sent only withexportNames: true; by default a span carries ids only, so a friend's or another home's name never leaves the host (a name comes fromtrace.names(), which includes friends' Bots and face-room names). The feed has no model name and no token counts, so those GenAI attributes are absent, not guessed. A failed send keeps the cursor, so the next flush retries the same closed spans (tested). The cursor re-reads records at its last timestamp (>=), because akecan share the millisecond of itsks(or atethe time of the last record); a span already sent is deduplicated by id, so a same-millisecond record is neither skipped nor sent twice. - Durable alerts (#584) keep their state in the plugin process, not in the trace: the tracker opens one alert per condition (
errors:<bot>,waiting:<bot>,stuck:<exchange>) and resolves it when the condition clears, keeping the resolution readable for a minute so a reader polling slower than the transition still sees that it cleared.changedis true only when something opened or resolved; a detail that moves while the alert keeps holding is not a transition.stuckuses the same 20 open requests the window already carries and a 15 min default threshold (stuckMs), which the issue left open. A window that ends before the last one folded is answeredstalewith the state untouched, so a look-back never resolves a live alert. The state dies with the process: nothing is written, and a restart starts with no alerts rather than replaying old ones.
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)).
- Reading raw
session/eventin the plugin. Extend the core feed instead (plugin-runtime). - Adding a field copied from an event without a cleaner. Every trace field has one.
- Calling idle time "waiting". The core cannot tell a waiting Bot from a busy one.