// Local cache of the claude-usage agent (issue #50): // ~/.local/state/maximus-claude-usage/state.json. // // Two things live here, both rebuilt from scratch when the file is lost: // // * frozenDays — every Toronto day strictly before yesterday, empty days // included, as { models, projects } (the shape of an aggregate() open // day). A frozen day is never recomputed, and aggregate() skips every // transcript last written before the day after the newest frozen day: this // is what keeps a beat from re-reading the whole 30-day window. An empty // day left unfrozen would hold that threshold back forever. // Yesterday stays open: a session running past midnight still writes // yesterday's lines into a transcript touched today. // * lastGoodLimits — the last `ok` limits with their fetchedAt, served when // the current call fails, so the dashboard shows the last known values and // how old they are instead of nothing. // // The file holds no secret. It is still written mode 600, atomically (temp file // suffixed with the pid in the same directory, then rename), so a concurrent // reader sees the old state or the new one, never a truncated JSON. const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { shiftDay } = require("./aggregate.js"); const STATE_VERSION = 1; const RETENTION_DAYS = 35; const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; function defaultStateDir(home = os.homedir()) { return path.join(home, ".local", "state", "maximus-claude-usage"); } function stateFilePath(stateDir) { return path.join(stateDir, "state.json"); } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isDay(value) { return typeof value === "string" && DAY_RE.test(value) && shiftDay(value, 0) === value; } function emptyState() { return { version: STATE_VERSION, frozenDays: {}, lastGoodLimits: null }; } // Any problem — missing file, unreadable, truncated JSON, wrong shape — gives // the empty state: the beat then does a full scan and rebuilds the cache. // Entries are kept only in their expected shape; their counters are re-read // defensively by aggregate() anyway. function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) { let parsed; try { parsed = JSON.parse(String(readFile(file))); } catch { return emptyState(); } if (!isPlainObject(parsed)) return emptyState(); const state = emptyState(); if (isPlainObject(parsed.frozenDays)) { for (const [day, value] of Object.entries(parsed.frozenDays)) { if (!isDay(day) || !isPlainObject(value)) continue; state.frozenDays[day] = { models: isPlainObject(value.models) ? value.models : {}, projects: isPlainObject(value.projects) ? value.projects : {}, }; } } const last = parsed.lastGoodLimits; if ( isPlainObject(last) && typeof last.fetchedAt === "string" && !Number.isNaN(Date.parse(last.fetchedAt)) && Array.isArray(last.items) ) { state.lastGoodLimits = { fetchedAt: last.fetchedAt, items: last.items.filter(isPlainObject) }; } return state; } /** * Next state after one beat. Pure. * * @param {object} state current state (loadState) * @param {object} opts * @param {object} opts.openDays aggregate().openDays * @param {string} opts.today Toronto "YYYY-MM-DD" * @param {object} opts.limits fetchLimits() result * @param {boolean} [opts.freeze] false: freeze no new day (incomplete scan) */ function nextState(state, { openDays = {}, today, limits = null, freeze = true }) { const yesterday = shiftDay(today, -1); const oldest = shiftDay(today, -RETENTION_DAYS); const frozenDays = {}; for (const [day, value] of Object.entries(state.frozenDays || {})) { if (day >= oldest) frozenDays[day] = value; } // freeze: false after a scan that hit a read error (#54): a day frozen // with lines missing would stay wrong for RETENTION_DAYS. The next clean // beat freezes it instead. for (const [day, value] of Object.entries(freeze ? openDays || {} : {})) { if (!isDay(day) || day >= yesterday || day < oldest) continue; frozenDays[day] = { models: value.models || {}, projects: value.projects || {} }; } const lastGoodLimits = limits && limits.status === "ok" ? { fetchedAt: limits.fetchedAt, items: limits.items } : state.lastGoodLimits || null; return { version: STATE_VERSION, frozenDays, lastGoodLimits }; } // The `limits` block of the snapshot: the current result when it is `ok`, // otherwise its status and detail with the last good items and their date. function limitsForSnapshot(limits, state) { if (limits.status === "ok") { return { status: "ok", fetchedAt: limits.fetchedAt, items: limits.items }; } const out = { status: limits.status }; if (limits.detail) out.detail = limits.detail; const last = state && state.lastGoodLimits; out.fetchedAt = last ? last.fetchedAt : null; out.items = last ? last.items : []; return out; } // Atomic write: temp file in the same directory (rename is only atomic within // one file system), mode 600, then rename over the old file. function saveState(file, state) { const dir = path.dirname(file); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); const tmp = `${file}.${process.pid}.tmp`; try { fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 }); // writeFileSync only applies `mode` when it creates the file: a temp file // left behind by a killed run would keep its old bits otherwise. fs.chmodSync(tmp, 0o600); fs.renameSync(tmp, file); } catch (err) { try { fs.unlinkSync(tmp); } catch { // already gone } throw err; } } module.exports = { loadState, saveState, nextState, limitsForSnapshot, emptyState, defaultStateDir, stateFilePath, RETENTION_DAYS, STATE_VERSION, };