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/2619.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The timeline panel's **Copy report** now prints the window's hours in your own time zone, the same hours the header above the button shows, instead of the Bot host's: the panel sends its browser zone with the request and the host formats the report in it, including the `(+1 day)` suffix on a window that crosses your midnight. A zone the host cannot use falls back to its own clock, as before ([#2619](https://github.com/ThinkFlowLab/FlyBot/issues/2619)).
2 changes: 1 addition & 1 deletion docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +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.
- 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?, timeZone? }`. 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 sends its own browser zone (`timeZone`) with the request, and the host formats the window in it — including the day suffix — falling back to its own clock when the zone is not one it can use (#2619). 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 Down
4 changes: 4 additions & 0 deletions plugins/timeline/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,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 @@ -129,6 +129,10 @@
// 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 }) }
// The report prints the reader's clock, so the host is told which zone that is (#2619): the
// same browser zone the header above the button prints (`fmtClock`).
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (timeZone) asked.timeZone = timeZone
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
Expand All @@ -150,7 +154,7 @@

// ------------------------------------------------------------------ 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 157 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 @@ -261,7 +265,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 268 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 @@ -685,7 +689,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 692 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
42 changes: 37 additions & 5 deletions plugins/timeline/src/digest.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,56 @@ const percent = (part) => `${Math.round(part * 100)}%`
const turnWord = (n) => (n === 1 ? 'turn' : 'turns')
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) => {
// copied from the panel must not name a different time than the header above the button. The panel
// names the reader's zone with the request (#2619); a host that gets no usable one keeps its own
// clock, exactly as before.
const hostClock = (ms) => {
const at = new Date(ms)
return Number.isFinite(at.getTime()) ? `${pad(at.getHours())}:${pad(at.getMinutes())}` : ''
}
// Whole local calendar days between two instants: 0 inside one day, 1 across midnight.
const daysApart = (from, to) => {
const hostDaysApart = (from, to) => {
const day = (ms) => { const at = new Date(ms); return Number.isFinite(at.getTime()) ? new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime() : NaN }
const a = day(from)
const b = day(to)
return Number.isFinite(a) && Number.isFinite(b) ? Math.round((b - a) / 86_400_000) : 0
}
const zonePart = (formatter, ms, type) => formatter.formatToParts(ms).find(part => part.type === type)?.value
// The reader's clock in the zone the panel named. A name the runtime will not take is no zone at
// all, so the caller falls back to the host's clock and a bad zone never throws the report away.
const readerZone = (timeZone) => {
if (typeof timeZone !== 'string' || !timeZone.trim()) return undefined
const zone = timeZone.trim()
try {
new Intl.DateTimeFormat('en-CA', { timeZone: zone })
return {
clock: new Intl.DateTimeFormat('en-CA', { timeZone: zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }),
day: new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }),
}
} catch {
return undefined
}
}
const zonedClock = (zone, ms) => Number.isFinite(ms)
? `${zonePart(zone.clock, ms, 'hour')}:${zonePart(zone.clock, ms, 'minute')}`
: ''
// Whole calendar days in the reader's zone, read from that zone's own Y/M/D: a DST day is 23 or 25
// hours long but still one calendar day, and the host's midnight never decides the suffix (#2619).
const zonedDaysApart = (zone, from, to) => {
if (!Number.isFinite(from) || !Number.isFinite(to)) return 0
const day = (ms) => Date.UTC(Number(zonePart(zone.day, ms, 'year')), Number(zonePart(zone.day, ms, 'month')) - 1, Number(zonePart(zone.day, ms, 'day')))
const a = day(from)
const b = day(to)
return Number.isFinite(a) && Number.isFinite(b) ? Math.round((b - a) / 86_400_000) : 0
}

/**
* `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() } = {}) {
export function buildDigest(window = {}, { title = 'Team report', now = Date.now(), timeZone } = {}) {
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 ?? []]))
Expand Down Expand Up @@ -121,11 +151,13 @@ export function buildDigest(window = {}, { title = 'Team report', now = Date.now
unhealthy: bots.filter(bot => bot.counted >= ERROR_TURNS_MIN && bot.errorRate >= ERROR_RATE_MIN).map(bot => bot.id),
}

const zone = readerZone(timeZone)
const clock = zone ? ms => zonedClock(zone, ms) : hostClock
const days = zone ? zonedDaysApart(zone, window.from, window.to) : hostDaysApart(window.from, window.to)
const from = clock(window.from)
const to = clock(window.to)
// The clock prints HH:MM only, so a range that crosses local midnight would read backwards
// (#1787): name the extra days instead (`23:50–00:10 (+1 day)`), and leave a same-day range alone.
const days = daysApart(window.from, window.to)
const range = from && to ? `${from}–${to}${days > 0 ? ` (+${days} ${days === 1 ? 'day' : 'days'})` : ''}` : 'the window'
const lines = [`# ${title}`, `Window: ${range}${window.live === true ? ' · live' : ''}`]

Expand Down
10 changes: 9 additions & 1 deletion plugins/timeline/src/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e
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() })
// The panel names the reader's zone (#2619); it is passed through only when it looks like one.
const digest = (body = {}) => {
const timeZone = typeof body?.timeZone === 'string' ? body.timeZone.trim() : ''
return buildDigest(window(body), {
...(typeof body?.title === 'string' && body.title.trim() ? { title: body.title.trim().slice(0, 80) } : {}),
...(timeZone && timeZone.length <= 64 ? { timeZone } : {}),
now: now(),
})
}
const offDigest = adapter.handle(DIGEST_ENDPOINT, digest)
return {
trace, recorder, window, exportNow, exportWindow, health, alerts, alertView, digest,
Expand Down
76 changes: 76 additions & 0 deletions plugins/timeline/test/digest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,79 @@ test('#2543: a Bot busy the whole window reads as busy, not as a turn-less Bot',
// Control: a Bot with no turn and no busy time still reads as idle, exactly as before.
assert.match(buildDigest(make(), { now: 100_000 }).markdown, /## Writer\n- no turns in the window/)
})

// #2619: the report's hours are the reader's, and only the panel knows which zone that is. The host
// formats the window in the zone the panel sends; without that, a host in another zone printed its
// own hours next to a panel header showing the browser's (`fmtClock`), and a Shanghai reader behind
// a UTC host got `15:30–16:30` above a header that said `23:30–00:30`.
const READER_FROM = Date.parse('2026-11-06T15:30:00Z')
const READER_TO = Date.parse('2026-11-06T16:30:00Z')
const readerWindow = () => make({ from: READER_FROM, to: READER_TO, live: false })

test('#2619: the report prints the window in the zone the panel names', () => {
const zone = timeZone => buildDigest(readerWindow(), { now: READER_TO, timeZone }).range
assert.equal(zone('UTC'), '15:30–16:30')
assert.equal(zone('Asia/Shanghai'), '23:30–00:30 (+1 day)')
assert.equal(zone('America/New_York'), '10:30–11:30')
// The day suffix follows the reader's midnight too, not the host's: UTC crosses none, Shanghai
// crosses its own, and New York stays inside one day.
assert.equal(buildDigest(readerWindow(), { now: READER_TO, timeZone: 'UTC' }).line, '4 turns, 1 failed, 12.3k tokens in over 15:30–16:30.')
})

// A zone the browser named but the host cannot use must never break the report, and must never be
// silently replaced by another zone's hours: it falls back to today's host-zone answer.
test('#2619: an unknown zone falls back to the host zone and never throws', () => {
const fallback = buildDigest(readerWindow(), { now: READER_TO })
for (const timeZone of ['Mars/Olympus', '', ' ', null, 42, {}, 'A'.repeat(200)]) {
const digest = buildDigest(readerWindow(), { now: READER_TO, timeZone })
assert.equal(digest.range, fallback.range, String(timeZone))
assert.equal(digest.markdown, fallback.markdown, String(timeZone))
}
})

// Without a zone the report is what it printed before this change, byte for byte: the host's own
// clock, as a reminder or an older panel that names no zone still gets.
test('#2619: no zone keeps the report byte for byte as it was', () => {
const digest = buildDigest(readerWindow(), { now: READER_TO })
assert.equal(digest.markdown, [
'# Team report',
'Window: 23:30–00:30 (+1 day)',
'',
'## Chief (main)',
'- 4 turns, 1 failed (25%)',
'- busy 62% of the window',
'- tokens: 12.3k in / 1.2k out / 8.0k cached',
'- tools: web_search ×6 (1 failed), read ×2',
'',
'## Writer',
'- no turns in the window',
'- 1 request(s) unanswered',
'- look: 1 request(s) unanswered',
'',
'## Team',
'- 4 turns, 1 failed',
'- tokens: 12.3k in / 1.2k out (4 calls)',
'- longest wait: b → a, 12m',
].join('\n'))
assert.equal(digest.line, '4 turns, 1 failed, 12.3k tokens in over 23:30–00:30 (+1 day).')
})

// The zone crosses the wire as `body.timeZone`, trimmed and length-capped; anything else is not a
// zone and leaves the request exactly as it was.
test('#2619: the endpoint passes the panel\'s zone through and ignores anything else', () => {
const adapter = mockAdapter()
const timeline = createTimeline(adapter, { persist: false, now: () => Date.UTC(2026, 10, 6, 16, 30) })
try {
const ask = adapter.endpoints.get(DIGEST_ENDPOINT)
assert.equal(ask({ windowMs: 3_600_000, timeZone: 'UTC' }).range, '15:30–16:30')
assert.equal(ask({ windowMs: 3_600_000, timeZone: ' Asia/Shanghai ' }).range, '23:30–00:30 (+1 day)')
// No zone, and every shape that is not one: the host's own clock, exactly as before.
const host = ask({ windowMs: 3_600_000 }).range
assert.equal(host, '23:30–00:30 (+1 day)')
assert.equal(ask({ windowMs: 3_600_000, timeZone: 42 }).range, host)
assert.equal(ask({ windowMs: 3_600_000, timeZone: ' ' }).range, host)
assert.equal(ask({ windowMs: 3_600_000, timeZone: 'A'.repeat(65) }).range, host)
} finally {
timeline.dispose()
}
})
7 changes: 5 additions & 2 deletions plugins/timeline/test/panel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
root.rendering = true
for (const inst of root.instances.values()) inst.used = false
root.tree = expand({ type: Component, props, children: [] }, 'r')
for (const [path, inst] of [...root.instances]) {

Check warning on line 62 in plugins/timeline/test/panel.test.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.
if (!inst.used) { for (const slot of inst.hooks) slot?.cleanup?.(); root.instances.delete(path) }
}
const run = root.pending.splice(0)
Expand Down Expand Up @@ -543,12 +543,15 @@
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 }]])
// The report prints the reader's clock, so the request names the browser's zone (#2619) — the
// same one the header prints. A page without one sends no zone and the host keeps its own clock.
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
assert.deepEqual(asked, [['timeline/digest', { windowMs: 300_000, timeZone: zone }]])
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' }])
assert.deepEqual(asked.at(-1), ['timeline/digest', { windowMs: 60_000, timeZone: zone, 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/)
Expand Down
Loading