vps-health-api/claude-usage-history.js
le king fu c5ded9e6f8
All checks were successful
CI / verification (pull_request) Successful in 33s
CI / verification (push) Successful in 33s
fix(server): archive by latest date, never a day past reception
Review of PR #57, suggestions 1 and 2:
- the excluded open day is now the most recent date of the snapshot,
  not its last element, so the rule no longer depends on the order the
  days were sent in (the sanitizer does not sort them);
- a day dated past the server's UTC reception day is never archived, and
  GET /claude-usage/history no longer takes a month file dated past the
  current month as its default end. A skewed workstation clock could
  otherwise open history/2099-12.json and leave the default range empty.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:34:54 -04:00

330 lines
12 KiB
JavaScript

// Claude usage history (issue #55): the closed days of the Claude usage
// snapshot, kept past its 30-day window, one file per month.
//
// Layout: <dir of CLAUDE_USAGE_PATH>/history/YYYY-MM.json, each file
// { "schemaVersion": 1, "month": "YYYY-MM", "days": [{ "date", "models" }] }
// with the days sorted oldest first, in the very shape of a `days` entry of the
// snapshot (same per-day cleaner, exported by claude-usage.js).
//
// Two writers, two merge rules:
// - POST /claude-usage (index.js): the LATEST SNAPSHOT WINS. Every day the
// snapshot carries, except the last one (today, still open), replaces the
// archived value. A day leaving the window keeps the last value it had,
// the one the agent froze at D+2.
// - the one-shot import below (`--import`): the ARCHIVED DATE WINS. The live
// archive is the reference; a backfill only fills the dates it lacks.
//
// A month file that cannot be read back is never overwritten: it may hold days
// that left the window long ago, and rewriting it from one snapshot would lose
// them. That month is skipped and reported; the other months are written.
//
// No config and no environment read outside the CLI block at the bottom: every
// function takes the history directory as an argument. Dates are grouped by
// the YYYY-MM prefix of their string, never by a clock computation.
const fs = require("node:fs");
const path = require("node:path");
const { sanitizeDay } = require("./claude-usage.js");
const HISTORY_SCHEMA_VERSION = 1;
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
const MONTH_FILE_RE = /^\d{4}-(0[1-9]|1[0-2])\.json$/;
// GET /claude-usage/history: default span, and the widest one it serves.
const DEFAULT_MONTHS = 12;
const MAX_MONTHS = 36;
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isMonth(value) {
return typeof value === "string" && MONTH_RE.test(value);
}
function monthOf(date) {
return date.slice(0, 7);
}
// Calendar arithmetic on the two numbers of the string, no Date involved.
function addMonths(month, n) {
const index = Number(month.slice(0, 4)) * 12 + Number(month.slice(5, 7)) - 1 + n;
const year = Math.floor(index / 12);
return `${String(year).padStart(4, "0")}-${String((index % 12) + 1).padStart(2, "0")}`;
}
// Inclusive: 2026-01 to 2026-12 spans 12 months.
function monthSpan(from, to) {
const index = (m) => Number(m.slice(0, 4)) * 12 + Number(m.slice(5, 7));
return index(to) - index(from) + 1;
}
function byDate(a, b) {
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
}
// Replace or add each incoming date (preferExisting: false, the POST rule), or
// add only the dates not there yet (preferExisting: true, the import rule).
// Returns a new array sorted oldest first; neither input is modified.
function mergeDays(existing, incoming, { preferExisting = false } = {}) {
const merged = new Map();
for (const day of existing) merged.set(day.date, day);
for (const day of incoming) {
if (preferExisting && merged.has(day.date)) continue;
merged.set(day.date, day);
}
return [...merged.values()].sort(byDate);
}
// The days POST /claude-usage archives: every day of the snapshot except the
// most recent date (today, still open), whatever the order they were sent in,
// and never a date past `receivedDay` (the server's UTC date of reception,
// never behind the workstation's Toronto date): a day from a skewed clock
// would otherwise open a future month file that the read route would take
// as its default end.
function closedDays(days, receivedDay) {
const dated = days.filter((day) => day.date <= receivedDay);
if (!dated.length) return [];
const latest = dated.reduce((max, day) => (day.date > max ? day.date : max), dated[0].date);
return dated.filter((day) => day.date !== latest);
}
// YYYY-MM -> days of that month, in input order.
function groupByMonth(days) {
const months = new Map();
for (const day of days) {
const month = monthOf(day.date);
if (!months.has(month)) months.set(month, []);
months.get(month).push(day);
}
return months;
}
// A parsed month file, re-read through the snapshot's whitelist: the file
// lives on a writable bind-mount and is no more trusted than a payload.
// Returns the clean days, or null when the file is off-format as a whole (it
// is then "unreadable": served without it, and never overwritten). A single
// invalid day, or one dated outside the month, is dropped; on a repeated
// date, the later entry wins.
function sanitizeMonthFile(parsed, month) {
if (!isPlainObject(parsed)) return null;
if (parsed.schemaVersion !== HISTORY_SCHEMA_VERSION) return null;
if (parsed.month !== month || !Array.isArray(parsed.days)) return null;
const days = [];
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day !== undefined && monthOf(day.date) === month) days.push(day);
}
return mergeDays([], days);
}
function historyDir(claudeUsagePath) {
return path.join(path.dirname(claudeUsagePath), "history");
}
function monthPath(dir, month) {
return path.join(dir, `${month}.json`);
}
// { state: "absent" } | { state: "ok", days } | { state: "unreadable", code }
function readMonth(dir, month) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(monthPath(dir, month), "utf-8"));
} catch (err) {
if (err.code === "ENOENT") return { state: "absent" };
return { state: "unreadable", code: err.code || "parse_error" };
}
const days = sanitizeMonthFile(parsed, month);
if (days === null) return { state: "unreadable", code: "off_format" };
return { state: "ok", days };
}
// Same atomic pattern as writeClaudeUsage() in index.js: temp file in the SAME
// directory (pid-suffixed, so two containers of a rolling redeploy never share
// one), mode 600, then renameSync. A concurrent read sees the old month or the
// new one, never a truncated JSON. No sweep covers these temp files: an orphan
// is harmless, the reader only opens YYYY-MM.json.
function writeMonth(dir, month, days) {
const finalPath = monthPath(dir, month);
const tmpPath = path.join(dir, `.${month}.json.${process.pid}.${Date.now()}.tmp`);
const record = { schemaVersion: HISTORY_SCHEMA_VERSION, month, days };
try {
fs.writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
fs.renameSync(tmpPath, finalPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Merge `days` into their month files. Each month stands alone: an unreadable
// one is skipped (never overwritten), a failed write is reported, and the
// other months go on. Only a failure to create the directory throws.
//
// Returns { written, skipped, failed, added, kept }: month lists, plus the
// number of dates added and, under preferExisting, of dates already archived.
function archiveDays(dir, days, { preferExisting = false } = {}) {
const report = { written: [], skipped: [], failed: [], added: 0, kept: 0 };
if (!days.length) return report;
fs.mkdirSync(dir, { recursive: true });
for (const [month, incoming] of [...groupByMonth(days)].sort(([a], [b]) => (a < b ? -1 : 1))) {
const current = readMonth(dir, month);
if (current.state === "unreadable") {
report.skipped.push({ month, code: current.code });
continue;
}
const existing = current.state === "ok" ? current.days : [];
const known = new Set(existing.map((day) => day.date));
const merged = mergeDays(existing, incoming, { preferExisting });
try {
writeMonth(dir, month, merged);
} catch (err) {
report.failed.push({ month, code: err.code || "write_error" });
continue;
}
report.written.push(month);
for (const day of incoming) {
if (!known.has(day.date)) report.added += 1;
else if (preferExisting) report.kept += 1;
}
}
return report;
}
// The most recent month that has a file, or null (no directory, no file).
function lastArchivedMonth(dir) {
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
const months = names.filter((name) => MONTH_FILE_RE.test(name)).map((name) => name.slice(0, 7));
return months.length ? months.sort().at(-1) : null;
}
// Query string of GET /claude-usage/history -> { from, to } or { error }.
// Defaults: `to` is the month of the last archived day (the current month when
// nothing is archived yet, or when the last file is dated past it), or `from`
// itself when `from` is later; `from` is DEFAULT_MONTHS months back from `to`,
// `to` included.
function parseRange(fromRaw, toRaw, { lastArchived, currentMonth }) {
if (fromRaw !== null && !isMonth(fromRaw)) return { error: "from must be YYYY-MM" };
if (toRaw !== null && !isMonth(toRaw)) return { error: "to must be YYYY-MM" };
let to = toRaw;
if (to === null) {
to = lastArchived && lastArchived <= currentMonth ? lastArchived : currentMonth;
if (fromRaw !== null && fromRaw > to) to = fromRaw;
}
const from = fromRaw !== null ? fromRaw : addMonths(to, -(DEFAULT_MONTHS - 1));
if (from > to) return { error: "from must not be after to" };
if (monthSpan(from, to) > MAX_MONTHS) return { error: `range must not exceed ${MAX_MONTHS} months` };
return { from, to };
}
// Every archived day from `from` to `to` (both YYYY-MM, inclusive), oldest
// first. An absent month contributes nothing; an unreadable one is listed in
// `unreadable` and contributes nothing either.
function readHistory(dir, from, to) {
const days = [];
const unreadable = [];
for (let month = from; month <= to; month = addMonths(month, 1)) {
const current = readMonth(dir, month);
if (current.state === "ok") days.push(...current.days);
else if (current.state === "unreadable") unreadable.push({ month, code: current.code });
}
return { days, unreadable };
}
// Import file of the one-shot backfill: { days: [...] }. Strict, since it is
// written once by hand: any entry that is not a clean day, or a repeated date,
// refuses the whole file. Returns the days, or null.
function parseImport(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isPlainObject(parsed) || !Array.isArray(parsed.days)) return null;
const days = [];
const seen = new Set();
for (const entry of parsed.days) {
const day = sanitizeDay(entry);
if (day === undefined || seen.has(day.date)) return null;
seen.add(day.date);
days.push(day);
}
return days;
}
// `node claude-usage-history.js --import <file|->`, run inside the container
// (docker exec, as `node`): merges a backfill into the live archive, the
// archived dates winning. Prints counts only, never a day's content.
function runImport(argv, env, io) {
if (argv.length !== 2 || argv[0] !== "--import") {
io.err("usage: node claude-usage-history.js --import <file|->");
return 2;
}
let raw;
try {
raw = argv[1] === "-" ? fs.readFileSync(0, "utf-8") : fs.readFileSync(argv[1], "utf-8");
} catch (err) {
io.err(`import: cannot read the import file (${err.code || "read_error"})`);
return 1;
}
const days = parseImport(raw);
if (days === null) {
io.err("import: invalid import file, nothing written");
return 1;
}
const dir = historyDir(env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json");
let report;
try {
report = archiveDays(dir, days, { preferExisting: true });
} catch (err) {
io.err(`import: cannot create the history directory (${err.code || "error"})`);
return 1;
}
io.out(
`import: ${days.length} day(s) read, ${report.added} added, ${report.kept} already archived, ` +
`${report.written.length} month(s) written`,
);
for (const { month, code } of report.skipped) io.err(`import: month ${month} skipped, unreadable (${code})`);
for (const { month, code } of report.failed) io.err(`import: month ${month} not written (${code})`);
return report.skipped.length || report.failed.length ? 1 : 0;
}
if (require.main === module) {
process.exitCode = runImport(process.argv.slice(2), process.env, {
out: (line) => console.log(line),
err: (line) => console.error(line),
});
}
module.exports = {
isMonth,
monthOf,
addMonths,
monthSpan,
mergeDays,
groupByMonth,
closedDays,
sanitizeMonthFile,
historyDir,
readMonth,
writeMonth,
archiveDays,
lastArchivedMonth,
parseRange,
readHistory,
parseImport,
runImport,
HISTORY_SCHEMA_VERSION,
DEFAULT_MONTHS,
MAX_MONTHS,
};