From fe89834e8bb4fca4210d4c5a29593bf5dfb04c68 Mon Sep 17 00:00:00 2001 From: Jacobo Ortiz Date: Wed, 7 Oct 2026 20:36:14 -0500 Subject: [PATCH] fix(pulse): cut the usage day in the principal's timezone 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 --- .../LIFEOS/PULSE/Performance/module.ts | 13 +- LifeOS/install/LIFEOS/PULSE/modules/usage.ts | 8 +- LifeOS/install/LIFEOS/PULSE/package.json | 1 + .../install/LIFEOS/PULSE/test/day-key.test.ts | 174 ++++++++++++++++++ LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts | 41 +++++ .../install/LIFEOS/TOOLS/UsageAggregator.ts | 13 +- 6 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts diff --git a/LifeOS/install/LIFEOS/PULSE/Performance/module.ts b/LifeOS/install/LIFEOS/PULSE/Performance/module.ts index 79d7c3a09b..38d446262a 100644 --- a/LifeOS/install/LIFEOS/PULSE/Performance/module.ts +++ b/LifeOS/install/LIFEOS/PULSE/Performance/module.ts @@ -13,6 +13,7 @@ import { join } from "path" import { existsSync, readFileSync } from "fs" import { homedir } from "node:os"; +import { dayKey, principalTimeZone } from "../../TOOLS/LifeosConfig" const HOME = process.env.HOME ?? process.env.USERPROFILE ?? homedir() const LIFEOS_DIR = join(HOME, ".claude", "LIFEOS") @@ -91,10 +92,11 @@ function handleCostApi(url: URL): Response { totalTokens += s.totalTokens ?? 0 } - // Aggregate by day + // Aggregate by day (cut in the principal's timezone) + const tz = principalTimeZone() const dailyCosts: Record = {} for (const s of filtered) { - const day = (s.lastTimestamp || s.firstTimestamp || "").slice(0, 10) + const day = dayKey(s.lastTimestamp || s.firstTimestamp, tz) if (day) dailyCosts[day] = (dailyCosts[day] ?? 0) + (s.costTotal ?? 0) } @@ -187,15 +189,16 @@ function handleFailuresApi(): Response { const totalCalls = activity.length + failures.length const overallRate = totalCalls > 0 ? Math.round((totalFailures / totalCalls) * 10000) / 100 : 0 - // Daily failure trend (last 7 days) + // Daily failure trend (last 7 days, cut in the principal's timezone) + const tz = principalTimeZone() const dailyFailures: Record = {} const dailyTotal: Record = {} for (const f of failures) { - const day = (f.timestamp || "").slice(0, 10) + const day = dayKey(f.timestamp, tz) if (day) dailyFailures[day] = (dailyFailures[day] ?? 0) + 1 } for (const a of activity) { - const day = (a.timestamp || "").slice(0, 10) + const day = dayKey(a.timestamp, tz) if (day) dailyTotal[day] = (dailyTotal[day] ?? 0) + 1 } diff --git a/LifeOS/install/LIFEOS/PULSE/modules/usage.ts b/LifeOS/install/LIFEOS/PULSE/modules/usage.ts index e17f12dca1..7666c23d9a 100644 --- a/LifeOS/install/LIFEOS/PULSE/modules/usage.ts +++ b/LifeOS/install/LIFEOS/PULSE/modules/usage.ts @@ -16,6 +16,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { dayKey, principalTimeZone } from "../../TOOLS/LifeosConfig"; const MODULE_NAME = "usage"; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); @@ -67,10 +68,11 @@ function sumDays(days: DayAgg[]): Totals { } function round2(t: Totals): Totals { return { ...t, costUsd: Math.round(t.costUsd * 100) / 100 }; } -/** ISO date string N days before today (UTC). */ +/** Date key N days before today, where "today" is the principal's local day (see dayKey). */ function daysAgo(n: number): string { - const ms = Date.now() - n * 86_400_000; - return new Date(ms).toISOString().slice(0, 10); + const d = new Date(`${dayKey(new Date().toISOString(), principalTimeZone())}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - n); + return d.toISOString().slice(0, 10); } function isoWeek(date: string): string { const d = new Date(date + "T00:00:00Z"); diff --git a/LifeOS/install/LIFEOS/PULSE/package.json b/LifeOS/install/LIFEOS/PULSE/package.json index 154cd6c157..6f469e382a 100644 --- a/LifeOS/install/LIFEOS/PULSE/package.json +++ b/LifeOS/install/LIFEOS/PULSE/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "start": "bun run pulse.ts", + "test": "bun test", "install-service": "bash manage.sh install", "status": "bash manage.sh status" }, diff --git a/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts b/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts new file mode 100644 index 0000000000..1331536309 --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/test/day-key.test.ts @@ -0,0 +1,174 @@ +/** + * Usage days are cut in the principal's timezone, consistently across the + * UsageAggregator (usage-daily.jsonl), the Usage tab and the Performance tab. + * + * One synthetic instant, 2026-03-09T03:00:00Z: March 9 in UTC, still March 8 in + * America/Bogota (UTC-5, no DST). Every site must agree on the local day when a + * zone is configured, and must reproduce the UTC day when no config is found. + * + * Run: cd LIFEOS/PULSE && bun test (bun:test + node builtins only; writes to a temp dir) + */ +import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const INSTANT = "2026-03-09T03:00:00.000Z"; +const UTC_DAY = "2026-03-09"; +const BOGOTA_DAY = "2026-03-08"; + +const PULSE_DIR = join(import.meta.dir, ".."); +const TOOLS_DIR = join(PULSE_DIR, "..", "TOOLS"); +const AGGREGATOR = join(TOOLS_DIR, "UsageAggregator.ts"); + +const root = mkdtempSync(join(tmpdir(), "lifeos-day-key-")); +// Pulse modules read HOME / CLAUDE_CONFIG_DIR at import time; the aggregator gets its own tree +// because it overwrites usage-daily.jsonl, which the Usage tab fixture must keep. +const home = join(root, "home"); +const pulseObsDir = join(home, ".claude", "LIFEOS", "MEMORY", "OBSERVABILITY"); +const aggDir = join(root, "agg"); +const aggObsDir = join(aggDir, "LIFEOS", "MEMORY", "OBSERVABILITY"); +const configs = { + bogota: join(root, "bogota.toml"), + invalid: join(root, "invalid-zone.toml"), + missing: join(root, "does-not-exist.toml"), +}; +const originalEnv = { + HOME: process.env.HOME, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + LIFEOS_CONFIG_PATH: process.env.LIFEOS_CONFIG_PATH, +}; + +const configToml = (timezone: string) => + `[principal]\nname = "Test Principal"\ntimezone = "${timezone}"\n\n[da]\nname = "Test DA"\n\n[da.voices.main]\nvoice_id = "voice-test"\n`; +const jsonl = (rows: object[]) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n"; +const session = { + sessionId: "session-a", firstTimestamp: INSTANT, lastTimestamp: INSTANT, messageCount: 1, + totalTokens: 1000, costTotal: 1, primaryModel: "test-model", models: { "test-model": 1 }, +}; + +beforeAll(() => { + mkdirSync(pulseObsDir, { recursive: true }); + mkdirSync(aggObsDir, { recursive: true }); + mkdirSync(join(aggDir, "projects", "proj-test"), { recursive: true }); + writeFileSync(configs.bogota, configToml("America/Bogota")); + writeFileSync(configs.invalid, configToml("Not/AZone")); + + // Aggregator sources: a session rollup and a live transcript message, both at INSTANT. + writeFileSync(join(aggObsDir, "session-costs.jsonl"), jsonl([session])); + writeFileSync(join(aggDir, "projects", "proj-test", "session-b.jsonl"), jsonl([ + { type: "assistant", sessionId: "session-b", timestamp: INSTANT, message: { model: "test-model", usage: { input_tokens: 10, output_tokens: 5 } } }, + ])); + + // Performance tab sources at the same instant. + writeFileSync(join(pulseObsDir, "session-costs.jsonl"), jsonl([session])); + writeFileSync(join(pulseObsDir, "tool-failures.jsonl"), jsonl([{ timestamp: INSTANT, tool_name: "Bash" }])); + writeFileSync(join(pulseObsDir, "tool-activity.jsonl"), jsonl([{ timestamp: INSTANT, tool_name: "Bash" }])); + + // Usage tab store: which rows count as "today" and "this week" depends on the zone. + writeFileSync(join(pulseObsDir, "usage-daily.jsonl"), jsonl([ + { date: "2026-03-01", messages: 1, totalTokens: 1, costUsd: 1, models: {} }, + { date: BOGOTA_DAY, messages: 1, totalTokens: 10, costUsd: 10, models: {} }, + { date: UTC_DAY, messages: 1, totalTokens: 100, costUsd: 100, models: {} }, + ])); + + process.env.HOME = home; + process.env.CLAUDE_CONFIG_DIR = join(home, ".claude"); + setSystemTime(new Date(INSTANT)); +}); + +afterAll(() => { + setSystemTime(); + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +function runAggregator(configPath: string): Array<{ date: string; messages: number }> { + const env = { ...process.env, HOME: originalEnv.HOME ?? "", CLAUDE_CONFIG_DIR: aggDir, LIFEOS_CONFIG_PATH: configPath }; + const run = Bun.spawnSync([process.execPath, AGGREGATOR], { env, cwd: TOOLS_DIR, stdout: "pipe", stderr: "pipe" }); + if (run.exitCode !== 0) throw new Error(`UsageAggregator exited ${run.exitCode}: ${run.stderr.toString()}`); + return readFileSync(join(aggObsDir, "usage-daily.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)); +} + +async function usageSummary() { + const usage = await import("../modules/usage.ts"); + const res = await usage.handleRequest(new Request("http://localhost/api/usage/summary"), "/api/usage/summary"); + return (await res!.json()) as { today: { costUsd: number }; week: { costUsd: number } }; +} + +async function performanceDays() { + const perf = await import("../Performance/module.ts"); + perf.startPerformance({ enabled: true }); + const get = async (path: string) => (await perf.handlePerformanceRequest(new Request(`http://localhost${path}`)))!.json(); + const cost = await get("/api/performance/cost"); + const failures = await get("/api/performance/failures"); + return { + dailyCosts: cost.dailyCosts.map((d: { day: string }) => d.day), + trend: failures.trend.map((d: { day: string }) => d.day), + }; +} + +describe("LifeosConfig day helpers", () => { + test("dayKey cuts an instant in the given zone and rejects what does not parse", async () => { + const { dayKey } = await import("../../TOOLS/LifeosConfig"); + expect(dayKey(INSTANT, "America/Bogota")).toBe(BOGOTA_DAY); + expect(dayKey(INSTANT, "UTC")).toBe(UTC_DAY); + expect(dayKey("2026-03-08T22:00:00-05:00", "America/Bogota")).toBe(BOGOTA_DAY); // hook-style offset stamp + expect(dayKey("", "UTC")).toBeNull(); + expect(dayKey("not a timestamp", "UTC")).toBeNull(); + }); + + test("principalTimeZone reads the config and falls back to UTC on a missing or unknown zone", async () => { + const { principalTimeZone } = await import("../../TOOLS/LifeosConfig"); + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + expect(principalTimeZone()).toBe("America/Bogota"); + process.env.LIFEOS_CONFIG_PATH = configs.invalid; + expect(principalTimeZone()).toBe("UTC"); + process.env.LIFEOS_CONFIG_PATH = configs.missing; + expect(principalTimeZone()).toBe("UTC"); + }); +}); + +describe("UsageAggregator day bucket", () => { + test("lands on the local day with a configured zone", () => { + const rows = runAggregator(configs.bogota); + expect(rows.map((r) => r.date)).toEqual([BOGOTA_DAY]); + expect(rows[0].messages).toBe(2); // the rollup session and the live transcript share the day + }); + + test("keeps the UTC day without a config", () => { + const rows = runAggregator(configs.missing); + expect(rows.map((r) => r.date)).toEqual([INSTANT.slice(0, 10)]); + }); +}); + +describe("Usage tab (modules/usage.ts)", () => { + test("today and the week cut follow the local day", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + const summary = await usageSummary(); + expect(summary.today.costUsd).toBe(10); // March 8 locally + expect(summary.week.costUsd).toBe(111); // March 1 is within 7 days of March 8 + }); + + test("keeps the UTC day without a config", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.missing; + const summary = await usageSummary(); + expect(summary.today.costUsd).toBe(100); + expect(summary.week.costUsd).toBe(110); + }); +}); + +describe("Performance tab (Performance/module.ts)", () => { + test("daily costs and the failure trend follow the local day", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.bogota; + expect(await performanceDays()).toEqual({ dailyCosts: [BOGOTA_DAY], trend: [BOGOTA_DAY] }); + }); + + test("keeps the UTC day without a config", async () => { + process.env.LIFEOS_CONFIG_PATH = configs.missing; + expect(await performanceDays()).toEqual({ dailyCosts: [UTC_DAY], trend: [UTC_DAY] }); + }); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts index 5b47d9018d..4bc0351be9 100644 --- a/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts +++ b/LifeOS/install/LIFEOS/TOOLS/LifeosConfig.ts @@ -143,6 +143,47 @@ export function paiUserDir(): string { } } +// ─────────── Calendar days in the principal's timezone ─────────── + +const dayFormatters = new Map(); +function dayFormatter(tz: string): Intl.DateTimeFormat { + let f = dayFormatters.get(tz); + if (!f) { + f = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }); + dayFormatters.set(tz, f); + } + return f; +} + +/** + * The principal's IANA timezone from LIFEOS_CONFIG.toml, or "UTC" when the config is + * missing/invalid or names a zone Intl does not know. Never throws, so Pulse request + * paths and launchd jobs can call it unguarded (same shape as paiUserDir()). + */ +export function principalTimeZone(): string { + try { + const tz = loadLifeosConfig().principal.timezone; + dayFormatter(tz); // RangeError on an unknown zone, caught below + return tz; + } catch { + return "UTC"; + } +} + +/** + * Calendar day ("YYYY-MM-DD") of an instant in the given zone, or null when the + * timestamp does not parse. Pass a full timestamp (offset or Z), never a date-only + * key: a bare "YYYY-MM-DD" parses as UTC midnight and lands a day early west of UTC. + */ +export function dayKey(timestamp: string, tz: string): string | null { + if (typeof timestamp !== "string" || !timestamp) return null; + const d = new Date(timestamp); + if (Number.isNaN(d.getTime())) return null; + const parts = dayFormatter(tz).formatToParts(d); + const part = (type: string) => parts.find((p) => p.type === type)?.value ?? ""; + return `${part("year")}-${part("month")}-${part("day")}`; +} + // ─────────── Validation ─────────── function validateAndNormalize(raw: unknown, path: string): LifeosConfig { diff --git a/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts b/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts index 6f31051a35..e0434f6962 100755 --- a/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts +++ b/LifeOS/install/LIFEOS/TOOLS/UsageAggregator.ts @@ -19,19 +19,22 @@ * 1. MEMORY/OBSERVABILITY/session-costs.jsonl — historical per-session rollup (has real cost); froze ~2026-04-16 * 2. ~/.claude/projects//*.jsonl (+ subagents/) — live raw transcripts (~5-day window); cost via price table * - * OUTPUT: MEMORY/OBSERVABILITY/usage-daily.jsonl — one line per day, sorted: + * OUTPUT: MEMORY/OBSERVABILITY/usage-daily.jsonl — one line per day (cut in the principal's timezone), sorted: * { date, messages, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, * totalTokens, costUsd, models: { : { messages, totalTokens, costUsd } } } */ import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { dayKey, principalTimeZone } from "./LifeosConfig"; const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); const OBS_DIR = join(CLAUDE_DIR, "LIFEOS", "MEMORY", "OBSERVABILITY"); const SESSION_COSTS = join(OBS_DIR, "session-costs.jsonl"); const PROJECTS_DIR = join(CLAUDE_DIR, "projects"); const OUT_PATH = join(OBS_DIR, "usage-daily.jsonl"); +// Day boundaries follow [principal].timezone in LIFEOS_CONFIG.toml (UTC when unset or unknown). +const TIME_ZONE = principalTimeZone(); // Price per MILLION tokens (USD). Approximate list prices; used only for the live // transcript window (historical days carry real cost from session-costs.jsonl). @@ -63,10 +66,6 @@ interface DayAgg { const byDay = new Map(); const countedSessions = new Set(); -function dayOf(ts: string): string | null { - const m = /^(\d{4}-\d{2}-\d{2})/.exec(ts || ""); - return m ? m[1] : null; -} function ensureDay(date: string): DayAgg { let d = byDay.get(date); if (!d) { @@ -89,7 +88,7 @@ function ingestSessionCosts(): number { if (!line.trim()) continue; let r: any; try { r = JSON.parse(line); } catch { continue; } - const date = dayOf(r.firstTimestamp); + const date = dayKey(r.firstTimestamp, TIME_ZONE); if (!date) continue; if (r.sessionId) countedSessions.add(r.sessionId); sessions++; @@ -157,7 +156,7 @@ function ingestTranscripts(): { files: number; messages: number } { try { r = JSON.parse(line); } catch { continue; } if (r.type !== "assistant" || !r.message?.usage) continue; if (r.sessionId && countedSessions.has(r.sessionId)) continue; // de-dup vs session-costs - const date = dayOf(r.timestamp); + const date = dayKey(r.timestamp, TIME_ZONE); if (!date) continue; const model: string = r.message.model || "unknown"; const u = r.message.usage;