// Claude usage history (issue #55): the closed days of the Claude usage // snapshot, kept past its 30-day window, one file per month. // // Layout: /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 writeJsonAtomic() 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 `, 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 "); 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, };