vps-health-api/agent/claude-usage/state.js
le king fu 123b3c3c8c fix(agent): no double count across midnight, no freeze on a read error, bounded limits read
- aggregate: frozen days are dropped after deduplication, not before. A
  request straddling midnight whose best line sat on a day frozen since
  was counted again on the open day. On equal output the earliest line now
  wins (it was the first one read), so the choice no longer depends on
  which files a beat reads.
- aggregate: unreadable directories, failed stats (ENOENT included) and
  stream errors are counted in `readErrors`; a missing root is still an
  empty scan. The beat then freezes no new day (nextState freeze: false),
  still pushes, and logs `read_errors=N` with no path.
- limits: the response is read chunk by chunk and counted in bytes; past
  256 KiB the stream is cancelled instead of being read in full and
  measured in characters.
- state: a test now covers the chmodSync of a leftover temp file.

Each fix was checked by mutation (13 mutations, 13 red).

Refs #54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:29:29 -04:00

167 lines
5.8 KiB
JavaScript

// 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,
};