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>
163 lines
5.6 KiB
JavaScript
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,
|
|
};
|