vps-health-api/agent/claude-usage/backfill-history.js
le king fu cf72292967 feat(agent): one-shot backfill of the Claude usage history from local transcripts
agent/claude-usage/backfill-history.js runs by hand from the repo (never
from the cron install, the agent is unchanged). It rebuilds the closed days
with the agent's own aggregate() on past windows and writes { days } to the
--out file, which the server merges with `claude-usage-history.js --import`
(an already archived date wins).

Each 30-day window keeps only its 28 inner days and the next steps back 28
days, so a request straddling a window boundary is deduplicated by the
window that keeps it and counted once. The first window stops at D-2. The
walk is bounded by --max-windows (default 24) rather than guessed from file
mtimes, which say nothing of the oldest line a resumed session holds.

It never reads the credentials file, opens no connection, writes only the
--out file (mode 600, never overwritten) and prints counts only.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:44:45 -04:00

138 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
// One-shot backfill of the Claude usage history (issue #55).
//
// Run by hand from the repo, on the workstation that holds the transcripts.
// It is NOT copied into the cron install: the agent itself never changes.
//
// node agent/claude-usage/backfill-history.js --out <file> [--root <dir>] [--max-windows <n>]
//
// Rebuilds the closed days from ~/.claude/projects with the agent's own
// aggregate(), on past windows, and writes { days: [...] } to <file> in the
// archive's day format. The file then goes to the server and is merged with
// `node claude-usage-history.js --import`, where an already archived date
// always wins (see agent/README.md, "Backfilling the history").
//
// What it never does: read ~/.claude/.credentials.json, open a network
// connection, or write anything but <file>, which it refuses to overwrite.
// Output: counts only, never a path or a day's content.
//
// WINDOWS. aggregate() reads a 30-day window ending on `today`. Each window
// keeps only its 28 inner days and the next one steps back 28 days: every
// kept day D has D-1 and D+1 in the same window, so a request whose lines
// straddle a midnight is deduplicated by the window that keeps it, exactly as
// the agent does inside its own window, and is never counted on both sides of
// a window boundary. The first window keeps nothing after D-2, the last day
// the agent may have frozen: an open day never enters the archive this way.
//
// STOP. A fixed number of windows, --max-windows (default 24, about 22
// months), then the leading empty days are trimmed. No guess from file
// mtimes: an mtime is a file's LAST write, and a resumed session keeps lines
// far older than it, so "past the oldest mtime" is no proof of "past the
// oldest line". A full scan of ~600 MB takes a few seconds per window.
//
// Each window calls aggregate() with no frozen days, and only { date, models }
// of its days is kept: no project, no limit, nothing else of the snapshot.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { aggregate, torontoDay, shiftDay } = require("./aggregate.js");
const PREFIX = "claude-usage backfill:";
const KEPT_DAYS = 28;
const DEFAULT_MAX_WINDOWS = 24;
function isEmptyDay(day) {
return Object.keys(day.models).length === 0;
}
/**
* @param {object} opts
* @param {string} opts.root transcripts root
* @param {string} [opts.today] Toronto date "YYYY-MM-DD" (default: now)
* @param {number} [opts.maxWindows] hard cap on the number of windows
* @param {Function} [opts.openFile] passed through to aggregate(), for tests
* @returns {Promise<{ days: Array<{date, models}>, windows: number, readErrors: number }>}
*/
async function backfill({ root, today = torontoDay(Date.now()), maxWindows = DEFAULT_MAX_WINDOWS, openFile } = {}) {
const collected = [];
let readErrors = 0;
let windows = 0;
let keptEnd = shiftDay(today, -2);
while (windows < maxWindows) {
const result = await aggregate({
root,
frozenDays: {},
today: shiftDay(keptEnd, 1),
...(openFile ? { openFile } : {}),
});
windows += 1;
readErrors += result.readErrors;
// result.days: keptEnd-28 .. keptEnd+1; the inner 28 are kept.
const kept = result.days.slice(1, 1 + KEPT_DAYS);
collected.unshift(...kept);
keptEnd = shiftDay(kept[0].date, -1);
}
const first = collected.findIndex((day) => !isEmptyDay(day));
const days = first === -1 ? [] : collected.slice(first).map(({ date, models }) => ({ date, models }));
return { days, windows, readErrors };
}
function parseArgs(argv) {
const opts = { out: null, root: null, maxWindows: DEFAULT_MAX_WINDOWS };
for (let i = 0; i < argv.length; i += 2) {
const [flag, value] = [argv[i], argv[i + 1]];
if (value === undefined) return { error: `missing value after ${flag}` };
if (flag === "--out") opts.out = value;
else if (flag === "--root") opts.root = value;
else if (flag === "--max-windows") {
if (!/^[1-9]\d{0,2}$/.test(value)) return { error: "--max-windows must be a number from 1 to 999" };
opts.maxWindows = Number(value);
} else return { error: `unknown option ${flag}` };
}
if (!opts.out) return { error: "--out <file> is required" };
return { opts };
}
async function main(argv = process.argv.slice(2), env = process.env, io = { out: console.log, err: console.error }) {
const parsed = parseArgs(argv);
if (parsed.error) {
io.err(`${PREFIX} ${parsed.error}`);
io.err("usage: node agent/claude-usage/backfill-history.js --out <file> [--root <dir>] [--max-windows <n>]");
return 2;
}
const { out, maxWindows } = parsed.opts;
const root = parsed.opts.root || path.join(env.HOME || os.homedir(), ".claude", "projects");
const result = await backfill({ root, maxWindows });
try {
// wx: never overwrite, a second run must not clobber a file already sent.
fs.writeFileSync(out, `${JSON.stringify({ days: result.days })}\n`, { flag: "wx", mode: 0o600 });
} catch (err) {
io.err(`${PREFIX} output not written (code=${err.code || "write_error"})`);
return 1;
}
const span = result.days.length ? ` from ${result.days[0].date} to ${result.days.at(-1).date}` : "";
io.out(`${PREFIX} ${result.days.length} day(s)${span}, ${result.windows} window(s), read_errors=${result.readErrors}`);
if (result.readErrors > 0) {
io.err(`${PREFIX} warning: read_errors=${result.readErrors}, some days may be incomplete; rerun before importing`);
return 1;
}
return 0;
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
console.error(`${PREFIX} unexpected failure (code=${(err && err.code) || "error"})`);
process.exitCode = 1;
},
);
}
module.exports = { backfill, main, parseArgs, KEPT_DAYS, DEFAULT_MAX_WINDOWS };