Skip to content

fix(pulse): cut the usage day in the principal's timezone - #2294

Open
jacobo-ortiz wants to merge 1 commit into
danielmiessler:mainfrom
jacobo-ortiz:fix/pulse-day-in-principal-timezone
Open

jacobo-ortiz wants to merge 1 commit into
danielmiessler:mainfrom
jacobo-ortiz:fix/pulse-day-in-principal-timezone

Conversation

@jacobo-ortiz

Copy link
Copy Markdown

Problem

Three places cut the day in UTC, and two of them disagree with each other:

File What it does
TOOLS/UsageAggregator.ts buckets usage-daily.jsonl rows by the first ten characters of the session's first timestamp (the UTC date for Z-stamped rows)
PULSE/modules/usage.ts (daysAgo, buildSummary) computes today and the 7/30-day cuts from toISOString() and filters daily rows with d.date === today
PULSE/Performance/module.ts (the three .slice(0, 10) day keys) keys daily costs and the failure trend by the session's last timestamp, in UTC

For a principal away from UTC the day boundary is wrong by their offset: every evening request lands on the next calendar day, the Usage tab's "today" stays empty until UTC midnight, and the two tabs put the same session on different days.

Measured on a UTC-5 host over one month (2026-10-02, deduplicated transcripts priced per request): 16.3% of the month's cost has its requests between 00:00 and 05:00 UTC (19:00 to 24:00 local), so it is shown on the wrong local day. LifeosConfig already requires principal.timezone; nothing in Pulse read it for day math.

Fix

  • TOOLS/LifeosConfig.ts exports two helpers. principalTimeZone() returns the configured IANA zone when the config loads and Intl knows the zone, else "UTC", with the same try/catch shape as paiUserDir(), so Pulse paths and the launchd job never throw on a missing config. dayKey(timestamp, tz) returns the YYYY-MM-DD of an instant in that zone through a memoised Intl.DateTimeFormat (assembled from formatToParts, so no locale separators leak in), or null when the timestamp does not parse.
  • The aggregator's day bucket, daysAgo() in usage.ts (today, week, month and the 30-day models window derive from it; isoWeek is calendar arithmetic on the key and needs no change) and the three day keys in Performance/module.ts use them. daysAgo subtracts calendar days from the local key instead of multiples of 24 h, so a DST change cannot move the cut.
  • Rolling N x 24 h windows (cutoff = now - days * 86400000) are unchanged: they are windows, not day keys.
  • With "UTC", or with no config, every Z-stamped input yields the same day as before, byte for byte.

Migration and residue

  • The aggregator rebuilds usage-daily.jsonl from session-costs.jsonl and the live transcripts on every run and never reads or appends to the previous file, so rows written with the UTC cut keep their date only until the next run (nightly at 03:30, RunAtLoad, or a manual bun LIFEOS/TOOLS/UsageAggregator.ts).
  • tool-activity and tool-failures rows are stamped by the hooks with the writer's local offset; on an install with no valid LIFEOS_CONFIG.toml (UTC fallback) the Performance trend keys for those rows move from the writer's local day to the UTC day. With a configured zone they land where they did.
  • Outside this change: the aggregator still assigns a whole session to its first timestamp while the Performance tab uses the last, and the Performance session table keeps a UTC date in the UI. healthsync/store.ts has its own dayKey(epochMs, zone); the two could be unified later.

Test

PULSE/test/day-key.test.ts (bun:test only, synthetic fixtures in a temp dir, zone pinned through LIFEOS_CONFIG_PATH, clock pinned with setSystemTime): the helpers; the aggregator as a subprocess; both modules in-process. A request at 03:00Z with America/Bogota lands on the previous local day in all three sites (red before the fix: on the UTC day); without a config the UTC day is kept (green before and after). bun test from LIFEOS/PULSE: 8 pass; a test script is added to PULSE/package.json. First *.test.ts in the tree; happy to move it wherever you prefer tests to live.

Related: #2284 (item 5 is this defect; it suggested a separate local-day series, but usage-daily.jsonl is rebuilt from scratch on every run and has one reader, so this re-cuts the existing series instead) and #1783 (same class of defect, in healthsync).

Three places cut the day in UTC: UsageAggregator buckets usage-daily.jsonl
rows by the first ten characters of a session's first timestamp (the UTC
date for Z-stamped rows), modules/usage.ts computes today and the 7/30-day
cuts from toISOString(), and Performance/module.ts keys its daily costs
and failure trend by timestamp.slice(0, 10). For a principal west of UTC
every request made in the evening lands on the next calendar day, so the
Usage tab's "today" is empty until UTC midnight and the two tabs disagree
about what happened on a given date. Rolling N x 24 h windows (the cost
and summary cutoffs) are windows, not day keys, and stay as they were.

LifeosConfig.ts, which already owns [principal].timezone, now exports two
helpers. principalTimeZone() returns the configured IANA zone when the
config loads and Intl knows the zone, else "UTC", with the same try/catch
shape as paiUserDir() so Pulse paths and the launchd job never throw on a
missing config. dayKey(timestamp, tz) returns the "YYYY-MM-DD" of an
instant in that zone through a memoised Intl.DateTimeFormat, or null when
the timestamp does not parse. The aggregator's day bucket, daysAgo() in
usage.ts (today, week, month and the 30-day models window all derive from
it; isoWeek is calendar arithmetic on the key and needs no change) and the
three day keys in Performance/module.ts use them. With "UTC" every
Z-stamped input yields the same day as before, byte for byte.

Migration: the aggregator rebuilds usage-daily.jsonl from
session-costs.jsonl and the live transcripts on every run and never reads
or appends to the previous file, so rows written with the UTC cut keep
their date only until the next run (nightly at 03:30, RunAtLoad, or a
manual `bun LIFEOS/TOOLS/UsageAggregator.ts`), which re-cuts every row
that still has a source. Known residue: tool-activity and tool-failures
rows are stamped by the hooks with the principal's local offset, so on an
install without a valid LIFEOS_CONFIG.toml (UTC fallback) the Performance
trend keys for those rows move from the writer's local day to the UTC day;
with a configured zone they land where they did. The aggregator still
assigns a whole session to its first timestamp while the Performance tab
uses the last, and the Performance session table keeps a UTC date in the
UI; both are outside this change.

Test: LIFEOS/PULSE/test/day-key.test.ts (bun:test only, synthetic fixtures
in a temp dir) drives the helpers, the aggregator as a subprocess and both
modules in-process; a `test` script is added to PULSE/package.json.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant