From c7d77e49437b0e7c46c60165f5e82c68b2a53c8c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 00:29:25 +0800 Subject: [PATCH] Print the timeline report's window in the reader's zone, not the host's The digest formatted the window with the host process's clock while the panel header above the Copy button used the browser's, so a Shanghai reader behind a UTC host copied a report that disagreed with the header. The panel now names its own browser zone with the request, and the host formats the window hours and the (+N day) suffix in that zone, keeping its own clock when the zone is missing or unusable. Refs #2619 --- changelog.d/2619.fixed.md | 1 + docs/subsystems/timeline.md | 2 +- plugins/timeline/client.js | 4 ++ plugins/timeline/src/digest.js | 42 +++++++++++++-- plugins/timeline/src/timeline.js | 10 +++- plugins/timeline/test/digest.test.js | 76 ++++++++++++++++++++++++++++ plugins/timeline/test/panel.test.js | 7 ++- 7 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2619.fixed.md diff --git a/changelog.d/2619.fixed.md b/changelog.d/2619.fixed.md new file mode 100644 index 000000000..deebde922 --- /dev/null +++ b/changelog.d/2619.fixed.md @@ -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)). diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index fa898ec6b..814efe780 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -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 diff --git a/plugins/timeline/client.js b/plugins/timeline/client.js index c37cd3dcc..ffac9cdaa 100644 --- a/plugins/timeline/client.js +++ b/plugins/timeline/client.js @@ -129,6 +129,10 @@ window.__ModuleLoader__.load({ // 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 diff --git a/plugins/timeline/src/digest.js b/plugins/timeline/src/digest.js index 67114174c..47c0f264c 100644 --- a/plugins/timeline/src/digest.js +++ b/plugins/timeline/src/digest.js @@ -41,18 +41,48 @@ 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, @@ -60,7 +90,7 @@ const daysApart = (from, to) => { * 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 ?? []])) @@ -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' : ''}`] diff --git a/plugins/timeline/src/timeline.js b/plugins/timeline/src/timeline.js index 3dbcd975a..941522e9f 100644 --- a/plugins/timeline/src/timeline.js +++ b/plugins/timeline/src/timeline.js @@ -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, diff --git a/plugins/timeline/test/digest.test.js b/plugins/timeline/test/digest.test.js index dc0b94699..d96bb578d 100644 --- a/plugins/timeline/test/digest.test.js +++ b/plugins/timeline/test/digest.test.js @@ -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() + } +}) diff --git a/plugins/timeline/test/panel.test.js b/plugins/timeline/test/panel.test.js index 34c3f12a2..ba268d596 100644 --- a/plugins/timeline/test/panel.test.js +++ b/plugins/timeline/test/panel.test.js @@ -543,12 +543,15 @@ test('#582: the data layer asks for the report at the window the panel shows', a 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/)