vps-health-api/agent/claude-usage/state.js
le king fu 2c95330ffa feat(agent): add the claude-usage agent: plan limits, local cache, push
limits.js reads the Claude Code OAuth credentials read-only and calls the
pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no
retry, never a refresh); detail codes come from a fixed enum only.
state.js freezes every Toronto day before yesterday, empty ones included,
prunes past 35 days, keeps the last good limits, and writes atomically in
mode 600. push-claude-usage.js writes the cache before pushing, refuses a
non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and
prints no token. run-claude-usage.sh takes a non-blocking flock so a beat
that overlaps the previous one exits 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:28:33 -04:00

163 lines
5.6 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
*/
function nextState(state, { openDays = {}, today, limits = null }) {
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;
}
for (const [day, value] of Object.entries(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,
};