POST /claude-usage now merges every day of the sanitized snapshot except the last one (today, still open) into <dir of CLAUDE_USAGE_PATH>/history/ YYYY-MM.json, after the snapshot itself is written. The latest snapshot wins, so a day leaving the 30-day window keeps the value the agent froze at D+2. Each month file is written atomically (same-dir temp, mode 600, rename). A month file that cannot be read back is never overwritten, and an archive failure is logged (months and error codes only) without changing the 204. GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM (read token) serves the archived days, defaulting to the 12 months up to the last archived month, 400 on a bad format, from > to or a span over 36 months. Month files are re-read through the snapshot's per-day whitelist, now exported from claude-usage.js as sanitizeDay(). The new module also carries the one-shot import command used by the backfill (`node claude-usage-history.js --import <file|->`), where an already archived date always wins. Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
12 KiB
JavaScript
315 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);
|
|
}
|
|
|
|
// 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 `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 || 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,
|
|
sanitizeMonthFile,
|
|
historyDir,
|
|
readMonth,
|
|
writeMonth,
|
|
archiveDays,
|
|
lastArchivedMonth,
|
|
parseRange,
|
|
readHistory,
|
|
parseImport,
|
|
runImport,
|
|
HISTORY_SCHEMA_VERSION,
|
|
DEFAULT_MONTHS,
|
|
MAX_MONTHS,
|
|
};
|