From 2c95330ffa06c4ca41305aab831524a563043aa0 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 4 Oct 2026 09:28:33 -0400 Subject: [PATCH] feat(agent): add the claude-usage agent: plan limits, local cache, push 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 --- __tests__/agent-claude-usage.test.js | 929 ++++++++++++++++++ .../fixtures/claude-usage/oauth-usage.json | 53 + agent/claude-usage/limits.js | 276 ++++++ agent/claude-usage/push-claude-usage.js | 238 +++++ agent/claude-usage/run-claude-usage.sh | 85 ++ agent/claude-usage/state.js | 163 +++ 6 files changed, 1744 insertions(+) create mode 100644 __tests__/fixtures/claude-usage/oauth-usage.json create mode 100644 agent/claude-usage/limits.js create mode 100755 agent/claude-usage/push-claude-usage.js create mode 100755 agent/claude-usage/run-claude-usage.sh create mode 100644 agent/claude-usage/state.js diff --git a/__tests__/agent-claude-usage.test.js b/__tests__/agent-claude-usage.test.js index 4452043..06fe8e6 100644 --- a/__tests__/agent-claude-usage.test.js +++ b/__tests__/agent-claude-usage.test.js @@ -634,3 +634,932 @@ describe("robustness", () => { ); }); }); + +// ============================================================================ +// The agent around the aggregation (issue #50): plan limits, local cache, +// snapshot assembly and delivery, cron wrapper. +// +// Nothing here touches the real ~/.claude or ~/.local, nor the network: every +// path (home, credentials, state dir, transcripts root) and `fetch` itself are +// injected, and the spawned processes run with HOME pointed at a temp dir. +// The two tokens below are checked, fragment by fragment, against every output +// that would reach `logger -t claude-usage`, as in agent.test.js. +// ============================================================================ + +const { execFile, spawn } = require("node:child_process"); +const { promisify } = require("node:util"); +const execFileAsync = promisify(execFile); + +const { + fetchLimits, + parseUsage, + USAGE_URL, + DETAILS, +} = require("../agent/claude-usage/limits.js"); +const { + loadState, + saveState, + nextState, + limitsForSnapshot, + emptyState, + stateFilePath, + defaultStateDir, + RETENTION_DAYS, +} = require("../agent/claude-usage/state.js"); +const { + main: agentMain, + readConfig: agentReadConfig, + MAX_BODY_BYTES, + EXIT_OK, + EXIT_CONFIG, + EXIT_PUSH, +} = require("../agent/claude-usage/push-claude-usage.js"); +const { sanitizeClaudeUsage } = require("../claude-usage.js"); + +const AGENT_DIR = path.join(__dirname, "..", "agent", "claude-usage"); +const PUSH_PATH = path.join(AGENT_DIR, "push-claude-usage.js"); +const WRAPPER_PATH = path.join(AGENT_DIR, "run-claude-usage.sh"); +const USAGE_FIXTURE = path.join(__dirname, "fixtures", "claude-usage", "oauth-usage.json"); + +// Mixed letters and digits, no run of five digits, so neither a port nor a +// timestamp can ever look like a leak. +const OAUTH_TOKEN = "sk9ant8oat2q7w4e1r6t3y5u"; +const INGEST_TOKEN = "c4u9g2i7n3g8e5s1t6k0z"; +const API_URL = "https://health.example.test"; +const NOW = Date.parse("2026-10-04T16:00:00.000Z"); // Toronto 2026-10-04 12:00 +const FUTURE = NOW + 3600 * 1000; +const LIMIT_STATUS_ENUM = ["ok", "token_expired", "http_error", "unavailable", "disabled"]; +const DETAIL_ENUM = Object.values(DETAILS); + +function usageFixture() { + return JSON.parse(fs.readFileSync(USAGE_FIXTURE, "utf-8")); +} + +function fragments(token, min = 5) { + const out = []; + for (let len = min; len <= token.length; len++) { + for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len)); + } + return out; +} + +function expectNoLeak(text) { + const haystack = String(text); + for (const token of [OAUTH_TOKEN, INGEST_TOKEN]) { + const leaked = fragments(token).filter((f) => haystack.includes(f)); + const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), ""); + expect(leaked.length, `token leaked — longest fragment found: "${worst}"`).toBe(0); + } +} + +function expectDetailInEnum(limits) { + expect(LIMIT_STATUS_ENUM).toContain(limits.status); + if (limits.detail !== undefined) { + const ok = DETAIL_ENUM.includes(limits.detail) || /^http_[1-5]\d\d$/.test(limits.detail); + expect(ok, `detail outside the enum: ${limits.detail}`).toBe(true); + } +} + +// A fetch double that records every call. `handler(url, init)` returns a +// Response (or a promise of one) or throws. +function makeFetch(handler) { + const calls = []; + const fn = async (url, init = {}) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }; + fn.calls = calls; + return fn; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// Anthropic answers with `usageBody`, the health API with `pushStatus`. +function routedFetch({ usage = () => jsonResponse(usageFixture()), push = () => new Response(null, { status: 204 }) } = {}) { + return makeFetch((url, init) => (url === USAGE_URL ? usage(url, init) : push(url, init))); +} + +// A fake home: /home with .claude/projects (optionally the fixture tree) +// and .claude/.credentials.json (optionally). +function makeHome({ fixtures = false, credentials } = {}) { + const home = path.join(tmpDir, "home"); + fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true }); + if (fixtures) { + fs.cpSync(FIXTURES, path.join(home, ".claude", "projects"), { recursive: true }); + } + const credsPath = path.join(home, ".claude", ".credentials.json"); + if (credentials !== undefined) { + fs.writeFileSync( + credsPath, + typeof credentials === "string" ? credentials : JSON.stringify(credentials), + { mode: 0o600 }, + ); + } + return { home, credsPath, stateFile: stateFilePath(defaultStateDir(home)) }; +} + +function goodCredentials(expiresAt = FUTURE) { + return { + claudeAiOauth: { + accessToken: OAUTH_TOKEN, + refreshToken: "rt9single8use7only6", + expiresAt, + scopes: ["user:inference"], + }, + }; +} + +function agentEnv(extra = {}) { + return { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, ...extra }; +} + +// Runs main() in process with everything injected; collects the output. +async function runAgent({ home, fetch, env = agentEnv(), argv = [], now = NOW, ...deps }) { + const out = []; + const err = []; + const code = await agentMain(argv, env, { + home, + fetch, + now, + log: (l) => out.push(l), + logError: (l) => err.push(l), + ...deps, + }); + return { code, stdout: out.join("\n"), stderr: err.join("\n") }; +} + +function pushCalls(fetch) { + return fetch.calls.filter((c) => c.url !== USAGE_URL); +} + +// --- limits: parsing ---------------------------------------------------------- + +describe("limits — the endpoint's answer", () => { + const limitsOpts = (extra = {}) => ({ + env: {}, + credentialsPath: "/unused", + readFile: () => JSON.stringify(goodCredentials()), + claudeCodeVersion: "2.1.283", + now: NOW, + ...extra, + }); + + it("maps the anonymized limits[] to items, weekly_scoped carrying model Fable", async () => { + const fetch = makeFetch(() => jsonResponse(usageFixture())); + const r = await fetchLimits(limitsOpts({ fetch })); + expect(r).toEqual({ + status: "ok", + fetchedAt: new Date(NOW).toISOString(), + items: [ + { kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null }, + { kind: "weekly_all", group: "weekly", percent: 50, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: null }, + { kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" }, + ], + }); + expect(fetch.calls).toHaveLength(1); + }); + + it.each([ + ["absent", (b) => delete b.limits], + ["empty", (b) => (b.limits = [])], + ["made of junk entries", (b) => (b.limits = [null, 3, "x", { percent: 9 }])], + ])("falls back to five_hour / seven_day when limits[] is %s", (_label, mutate) => { + const body = usageFixture(); + mutate(body); + expect(parseUsage(body)).toEqual([ + { kind: "session", group: "session", percent: 1, resetsAt: "2026-09-27T21:30:00.512345+00:00", model: null }, + { kind: "weekly_all", group: "weekly", percent: 50, resetsAt: "2026-10-01T07:00:00.512345+00:00", model: null }, + ]); + }); + + it("an all-null answer is unavailable/bad_response", async () => { + const body = Object.fromEntries(Object.keys(usageFixture()).map((k) => [k, null])); + const fetch = makeFetch(() => jsonResponse(body)); + const r = await fetchLimits(limitsOpts({ fetch })); + expect(r).toEqual({ status: "unavailable", detail: "bad_response", fetchedAt: null, items: [] }); + }); + + it("rounds and clamps percent to an integer 0-100, drops a non-number, normalizes resetsAt", () => { + const items = parseUsage({ + limits: [ + { kind: "a", group: "g", percent: 62.6, resets_at: 1790000000 }, + { kind: "b", group: "g", percent: 130, resets_at: "not a date" }, + { kind: "c", group: "g", percent: -3, resets_at: "Thu, 01 Oct 2026 07:00:00 GMT" }, + { kind: "d", percent: "50", scope: { model: { display_name: 42 } } }, + ], + }); + expect(items.map((i) => i.percent)).toEqual([63, 100, 0, undefined]); + expect("percent" in items[3]).toBe(false); + expect(items[3].group).toBe("d"); + expect(items[3].model).toBeNull(); + expect(items[0].resetsAt).toBe(new Date(1790000000 * 1000).toISOString()); + expect(items[1].resetsAt).toBeNull(); + expect(items[2].resetsAt).toBe("2026-10-01T07:00:00.000Z"); + }); + + it("keeps at most 12 items and no upstream key outside the whitelist", () => { + const limits = Array.from({ length: 20 }, (_, i) => ({ + kind: `k${i}`, group: "g", percent: 1, severity: "normal", resets_at: null, + scope: null, is_active: true, __proto__x: "junk", token: OAUTH_TOKEN, + })); + const items = parseUsage({ limits }); + expect(items).toHaveLength(12); + for (const it of items) { + expect(Object.keys(it).sort()).toEqual(["group", "kind", "model", "percent", "resetsAt", "severity"]); + } + expectNoLeak(JSON.stringify(items)); + }); +}); + +// --- limits: when no call is made ---------------------------------------------- + +describe("limits — cases that never call the endpoint", () => { + it.each([ + ["CLAUDE_USAGE_LIMITS=off", { env: { CLAUDE_USAGE_LIMITS: "off" } }, { status: "disabled" }], + ["CLAUDE_USAGE_LIMITS=OFF", { env: { CLAUDE_USAGE_LIMITS: " OFF " } }, { status: "disabled" }], + ["an expired token", { creds: JSON.stringify(goodCredentials(NOW - 1)) }, { status: "token_expired" }], + ["truncated credentials (rewrite in progress)", { creds: '{"claudeAiOauth":{"accessToken":"sk9ant8o' }, { status: "unavailable", detail: "parse_error" }], + ["absent credentials", { missing: true }, { status: "unavailable", detail: "no_credentials" }], + ["credentials without an access token", { creds: '{"claudeAiOauth":{}}' }, { status: "unavailable", detail: "no_credentials" }], + ["an unreadable credentials file", { unreadable: true }, { status: "unavailable", detail: "parse_error" }], + ["no Claude Code version", { version: null }, { status: "unavailable", detail: "no_version" }], + ])("%s", async (_label, c, expected) => { + const fetch = makeFetch(() => jsonResponse(usageFixture())); + const readFile = () => { + if (c.missing) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + if (c.unreadable) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return c.creds || JSON.stringify(goodCredentials()); + }; + const r = await fetchLimits({ + env: c.env || {}, + credentialsPath: "/unused", + readFile, + fetch, + claudeCodeVersion: c.version === null ? null : "2.1.283", + now: NOW, + }); + expect(fetch.calls).toHaveLength(0); + expect(r).toEqual({ ...expected, fetchedAt: null, items: [] }); + expectNoLeak(JSON.stringify(r)); + }); + + it("CLAUDE_USAGE_LIMITS=off does not even read the credentials", async () => { + const readFile = vi.fn(() => JSON.stringify(goodCredentials())); + const fetch = makeFetch(() => jsonResponse(usageFixture())); + await fetchLimits({ env: { CLAUDE_USAGE_LIMITS: "off" }, readFile, fetch, claudeCodeVersion: "2.1.283" }); + expect(readFile).not.toHaveBeenCalled(); + expect(fetch.calls).toHaveLength(0); + }); +}); + +// --- limits: the request and its failures -------------------------------------- + +describe("limits — the request", () => { + const base = (fetch, env = {}) => ({ + env, + credentialsPath: "/unused", + readFile: () => JSON.stringify(goodCredentials()), + fetch, + claudeCodeVersion: "2.1.283", + now: NOW, + }); + + it("sends the OAuth bearer to the pinned Anthropic URL only, redirects refused, whatever the environment says", async () => { + expect(USAGE_URL).toBe("https://api.anthropic.com/api/oauth/usage"); + const fetch = makeFetch(() => jsonResponse(usageFixture())); + await fetchLimits( + base(fetch, { + ANTHROPIC_BASE_URL: "https://evil.example", + ANTHROPIC_API_URL: "https://evil.example", + CLAUDE_USAGE_API_URL: "https://evil.example", + CLAUDE_USAGE_LIMITS_URL: "https://evil.example", + CLAUDE_USAGE_LIMITS: "on", + }), + ); + expect(fetch.calls).toHaveLength(1); + const { url, init } = fetch.calls[0]; + expect(url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(init.method).toBe("GET"); + expect(init.redirect).toBe("error"); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.headers).toMatchObject({ + Authorization: `Bearer ${OAUTH_TOKEN}`, + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "claude-code/2.1.283", + }); + }); + + it("limits.js reads no environment variable but CLAUDE_USAGE_LIMITS, and writes the URL once", () => { + const src = fs.readFileSync(path.join(AGENT_DIR, "limits.js"), "utf-8"); + const code = src + .split("\n") + .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*")) + .join("\n"); + expect([...code.matchAll(/\benv\.([A-Za-z_]+)/g)].map((m) => m[1])).toEqual(["CLAUDE_USAGE_LIMITS"]); + // process.env appears once, as the default of the injectable `env`. + expect(code.match(/process\.env/g)).toEqual(["process.env"]); + expect(code).toContain("env = process.env,"); + expect(code.match(/https:\/\//g)).toHaveLength(1); + expect(code).not.toMatch(/refresh/i); + }); + + it("a 429 is http_error/http_429, and the agent then serves the last good items with their fetchedAt", async () => { + const fetch = makeFetch(() => new Response("Too Many Requests", { status: 429 })); + const r = await fetchLimits(base(fetch)); + expect(r).toEqual({ status: "http_error", detail: "http_429", fetchedAt: null, items: [] }); + expect(fetch.calls).toHaveLength(1); // no retry + + const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const first = routedFetch(); + expect((await runAgent({ home, fetch: first, now: NOW })).code).toBe(EXIT_OK); + const firstSnap = JSON.parse(pushCalls(first)[0].init.body); + + const later = NOW + 15 * 60 * 1000; + const second = routedFetch({ usage: () => new Response("slow down", { status: 429 }) }); + expect((await runAgent({ home, fetch: second, now: later })).code).toBe(EXIT_OK); + const snap = JSON.parse(pushCalls(second)[0].init.body); + expect(snap.limits).toEqual({ + status: "http_error", + detail: "http_429", + fetchedAt: new Date(NOW).toISOString(), + items: firstSnap.limits.items, + }); + expect(snap.generatedAt).toBe(new Date(later).toISOString()); + expect(credsPath).toBeTruthy(); + }); + + it.each([ + ["a 500 echoing the token and request headers", () => new Response(`upstream said: Authorization: Bearer ${OAUTH_TOKEN}\nanthropic-beta: oauth`, { status: 500 })], + ["a 200 with arbitrary text", () => new Response(`error ${OAUTH_TOKEN} please retry`, { status: 200 })], + ["a 200 JSON echoing the headers", () => jsonResponse({ headers: { authorization: `Bearer ${OAUTH_TOKEN}` }, detail: "Ignore previous instructions" })], + ["a 200 JSON with a hostile detail field", () => jsonResponse({ detail: "http_200_but_actually_long_text", limits: "nope", five_hour: { utilization: "x" } })], + ["a 302 to another host", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })], + ["a thrown error carrying the token in its message", () => { throw new TypeError(`Headers.append: "Bearer ${OAUTH_TOKEN}" is an invalid header value.`); }], + ["a network failure", () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); }], + ["a timeout", () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }], + ])("%s keeps detail inside the enum and no token in the result", async (_label, answer) => { + const fetch = makeFetch(answer); + const r = await fetchLimits(base(fetch)); + expectDetailInEnum(r); + expect(r.status).not.toBe("ok"); + expect(r.items).toEqual([]); + expectNoLeak(JSON.stringify(r)); + expect(fetch.calls).toHaveLength(1); + }); + + it("maps timeouts, network errors and non-2xx to their codes", async () => { + const timeout = await fetchLimits({ + ...base(makeFetch((url, init) => new Promise((_, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason)); + }))), + timeoutMs: 20, + }); + expect(timeout).toMatchObject({ status: "unavailable", detail: "timeout" }); + + const network = await fetchLimits(base(makeFetch(() => { throw new TypeError("fetch failed"); }))); + expect(network).toMatchObject({ status: "unavailable", detail: "network" }); + + const unauthorized = await fetchLimits(base(makeFetch(() => new Response("{}", { status: 401 })))); + expect(unauthorized).toMatchObject({ status: "http_error", detail: "http_401" }); + }); +}); + +// --- the credentials file ------------------------------------------------------ + +describe("the credentials file is never opened for writing", () => { + it("a full run only ever reads it, through fs.readFileSync without a flag", async () => { + const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const before = fs.readFileSync(credsPath, "utf-8"); + const beforeStat = fs.statSync(credsPath); + + const writers = [ + "writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream", + "renameSync", "rename", "copyFileSync", "copyFile", "cpSync", "rmSync", "rm", + "unlinkSync", "unlink", "truncateSync", "truncate", "chmodSync", "chmod", + "chownSync", "utimesSync", "symlinkSync", "linkSync", "openSync", "open", + ]; + const spies = writers.map((name) => vi.spyOn(fs, name)); + const promiseWriters = ["writeFile", "appendFile", "open", "rename", "copyFile", "rm", "unlink", "truncate", "chmod"]; + const promiseSpies = promiseWriters.map((name) => vi.spyOn(fs.promises, name)); + const readSpy = vi.spyOn(fs, "readFileSync"); + + const fetch = routedFetch(); + const { code } = await runAgent({ home, fetch }); + expect(code).toBe(EXIT_OK); + expect(fetch.calls.filter((c) => c.url === USAGE_URL)).toHaveLength(1); + + const credReads = readSpy.mock.calls.filter((c) => c[0] === credsPath); + expect(credReads).toHaveLength(1); + expect(credReads[0][1]).toBe("utf-8"); + + for (const spy of [...spies, ...promiseSpies]) { + for (const args of spy.mock.calls) { + const touches = args.some((a) => String(a) === credsPath || String(a).startsWith(`${credsPath}.`)); + expect(touches, `${spy.getMockName()} touched the credentials file`).toBe(false); + } + } + // The state file, on the other hand, was written: the spies were live. + expect(spies[0].mock.calls.length + spies[6].mock.calls.length).toBeGreaterThan(0); + + expect(fs.readFileSync(credsPath, "utf-8")).toBe(before); + expect(fs.statSync(credsPath).mtimeMs).toBe(beforeStat.mtimeMs); + }); +}); + +// --- state ----------------------------------------------------------------------- + +describe("state — local cache", () => { + it.each([ + ["missing", null], + ["truncated", '{"version":1,"frozenDays":{"2026-09-'], + ["not an object", "[1,2,3]"], + ["binary junk", "\u0000\u0001ÿ"], + ])("a %s state.json gives the empty state", (_label, content) => { + const file = path.join(tmpDir, "state.json"); + if (content !== null) fs.writeFileSync(file, content); + expect(loadState(file)).toEqual(emptyState()); + }); + + it("keeps only well-formed frozen days and last good limits on reload", () => { + const file = path.join(tmpDir, "state.json"); + fs.writeFileSync( + file, + JSON.stringify({ + frozenDays: { + "2026-10-01": { models: { m: { input: 1 } }, projects: {} }, + "2026-02-30": { models: {}, projects: {} }, + "not-a-day": { models: {}, projects: {} }, + "2026-09-30": "junk", + "2026-09-29": { models: [], projects: null }, + }, + lastGoodLimits: { fetchedAt: "garbage", items: [] }, + }), + ); + const s = loadState(file); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-29", "2026-10-01"]); + expect(s.frozenDays["2026-09-29"]).toEqual({ models: {}, projects: {} }); + expect(s.lastGoodLimits).toBeNull(); + }); + + it("freezes every open day strictly before yesterday, empty ones included; yesterday and today stay open", () => { + const openDays = { + "2026-09-05": { models: {}, projects: {} }, + "2026-10-01": { models: { m: { input: 1 } }, projects: { p: { work: 1 } } }, + "2026-10-02": { models: {}, projects: {} }, + "2026-10-03": { models: { m: { input: 2 } }, projects: {} }, + "2026-10-04": { models: {}, projects: {} }, + }; + const s = nextState(emptyState(), { openDays, today: "2026-10-04", limits: { status: "disabled" } }); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-05", "2026-10-01", "2026-10-02"]); + expect(s.frozenDays["2026-10-02"]).toEqual({ models: {}, projects: {} }); + expect(s.frozenDays["2026-10-01"]).toEqual(openDays["2026-10-01"]); + }); + + it(`prunes frozen days older than ${RETENTION_DAYS} days`, () => { + const state = { + ...emptyState(), + frozenDays: { + "2026-08-29": { models: {}, projects: {} }, // today - 36 + "2026-08-30": { models: {}, projects: {} }, // today - 35 + "2026-09-20": { models: {}, projects: {} }, + }, + }; + const s = nextState(state, { openDays: {}, today: "2026-10-04", limits: { status: "disabled" } }); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-08-30", "2026-09-20"]); + }); + + it("updates lastGoodLimits on `ok` only", () => { + const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] }; + const s1 = nextState(emptyState(), { openDays: {}, today: "2026-10-04", limits: ok }); + expect(s1.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items }); + for (const failed of [ + { status: "http_error", detail: "http_429", fetchedAt: null, items: [] }, + { status: "token_expired", fetchedAt: null, items: [] }, + { status: "unavailable", detail: "timeout", fetchedAt: null, items: [] }, + { status: "disabled", fetchedAt: null, items: [] }, + ]) { + const s2 = nextState(s1, { openDays: {}, today: "2026-10-04", limits: failed }); + expect(s2.lastGoodLimits).toEqual(s1.lastGoodLimits); + expect(limitsForSnapshot(failed, s2)).toEqual({ + status: failed.status, + ...(failed.detail ? { detail: failed.detail } : {}), + fetchedAt: ok.fetchedAt, + items: ok.items, + }); + } + expect(limitsForSnapshot({ status: "token_expired", fetchedAt: null, items: [] }, emptyState())).toEqual({ + status: "token_expired", + fetchedAt: null, + items: [], + }); + }); + + it("writes atomically: mode 600, temp file suffixed with the pid in the same directory, then rename", () => { + const file = path.join(tmpDir, "deep", "state", "state.json"); + const writeSpy = vi.spyOn(fs, "writeFileSync"); + const renameSpy = vi.spyOn(fs, "renameSync"); + saveState(file, { ...emptyState(), frozenDays: { "2026-10-01": { models: {}, projects: {} } } }); + + const tmp = `${file}.${process.pid}.tmp`; + expect(writeSpy.mock.calls.map((c) => c[0])).toEqual([tmp]); + expect(renameSpy.mock.calls).toEqual([[tmp, file]]); + expect(fs.existsSync(tmp)).toBe(false); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(loadState(file).frozenDays).toEqual({ "2026-10-01": { models: {}, projects: {} } }); + }); + + it("the default state dir is ~/.local/state/maximus-claude-usage", () => { + expect(stateFilePath(defaultStateDir("/home/u"))).toBe("/home/u/.local/state/maximus-claude-usage/state.json"); + }); +}); + +// --- push-claude-usage: one beat ---------------------------------------------- + +describe("push-claude-usage — one beat", () => { + it("two consecutive beats around an empty day: the empty day is frozen, the second beat opens no file before the threshold", async () => { + const { home, stateFile } = makeHome({ credentials: undefined }); + const root = path.join(home, ".claude", "projects"); + const projectDir = path.join(root, "-home-tester-claude-code-alpha"); + fs.mkdirSync(projectDir, { recursive: true }); + const write = (name, lines, mtimeIso) => { + const file = path.join(projectDir, name); + fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); + setMtime(file, mtimeIso); + return file; + }; + // Lines on Oct 1 (Toronto 23:00), file last touched Oct 2 00:30 Toronto: + // Oct 2 itself has no line at all. Unfrozen, that empty day would hold the + // threshold on Oct 2 and this file would be re-read on every beat. + const old = write("old.jsonl", [line({ ts: "2026-10-02T03:00:00Z", output: 40 })], "2026-10-02T04:30:00Z"); + const recent = write( + "recent.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 7 }), line({ ts: "2026-10-04T15:00:00Z", output: 9 })], + "2026-10-04T15:30:00Z", + ); + const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" }); + + const firstOpened = []; + const fetch1 = routedFetch(); + const r1 = await runAgent({ + home, fetch: fetch1, env, + openFile: (f, o) => { firstOpened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r1.code).toBe(EXIT_OK); + expect(firstOpened.sort()).toEqual([old, recent].sort()); + + const frozen = JSON.parse(fs.readFileSync(stateFile, "utf-8")).frozenDays; + expect(frozen["2026-10-02"]).toEqual({ models: {}, projects: {} }); + expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(40); + expect(frozen["2026-10-03"]).toBeUndefined(); // yesterday stays open + expect(frozen["2026-10-04"]).toBeUndefined(); + expect(Object.keys(frozen)).toHaveLength(28); // the window minus yesterday and today + + const secondOpened = []; + const fetch2 = routedFetch(); + const r2 = await runAgent({ + home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000, + openFile: (f, o) => { secondOpened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r2.code).toBe(EXIT_OK); + expect(secondOpened).toEqual([recent]); + + const snap1 = JSON.parse(pushCalls(fetch1)[0].init.body); + const snap2 = JSON.parse(pushCalls(fetch2)[0].init.body); + expect(snap2.days).toEqual(snap1.days); // frozen days served as they were counted + expect(snap2.projects).toEqual(snap1.projects); + }); + + it("a corrupt state.json gives a full beat without failing, and is rewritten", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, '{"frozenDays":{"2026-10-0'); + const opened = []; + const fetch = routedFetch(); + const r = await runAgent({ + home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }), + openFile: (f, o) => { opened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r.code).toBe(EXIT_OK); + expect(opened).toHaveLength(2); // the whole fixture tree + expect(Object.keys(loadState(stateFile).frozenDays).length).toBeGreaterThan(0); + expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600); + }); + + it("writes the cache before the push: a failed push still leaves the frozen days on disk", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + const fetch = routedFetch({ push: () => new Response("nope", { status: 503 }) }); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toContain("push failed: HTTP 503"); + expect(Object.keys(loadState(stateFile).frozenDays).length).toBe(28); + }); + + it("the snapshot built on the fixtures goes through sanitizeClaudeUsage unchanged", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + const r = await runAgent({ home, fetch }); + expect(r.code).toBe(EXIT_OK); + const sent = JSON.parse(pushCalls(fetch)[0].init.body); + + expect(sent.schemaVersion).toBe(1); + expect(sent.timeZone).toBe("America/Toronto"); + expect(sent.claudeCodeVersion).toBe("2.1.283"); + expect(sent.days).toHaveLength(30); + expect(sent.days[29].date).toBe("2026-10-04"); + expect(sent.limits.status).toBe("ok"); + expect(sent.limits.items.find((i) => i.kind === "weekly_scoped").model).toBe("Fable"); + expect(sent.projects.length).toBeGreaterThan(0); + + const { snapshot, error } = sanitizeClaudeUsage(sent); + expect(error).toBeUndefined(); + expect(snapshot).toEqual(sent); + }); + + it.each([ + ["ok limits", { credentials: goodCredentials() }, {}], + ["expired token", { credentials: goodCredentials(NOW - 1) }, {}], + ["no credentials", {}, {}], + ["limits off", { credentials: goodCredentials() }, { CLAUDE_USAGE_LIMITS: "off" }], + ])("with %s, the snapshot still round-trips through the sanitizer", async (_label, homeOpts, envExtra) => { + const { home } = makeHome({ fixtures: true, ...homeOpts }); + const fetch = routedFetch(); + expect((await runAgent({ home, fetch, env: agentEnv(envExtra) })).code).toBe(EXIT_OK); + const sent = JSON.parse(pushCalls(fetch)[0].init.body); + expect(sanitizeClaudeUsage(sent).snapshot).toEqual(sent); + }); + + it("sends the OAuth bearer only to api.anthropic.com and the ingest token only to the health API", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + await runAgent({ home, fetch }); + expect(fetch.calls.map((c) => c.url)).toEqual([USAGE_URL, `${API_URL}/claude-usage`]); + + const [usage, push] = fetch.calls; + expect(usage.init.headers.Authorization).toBe(`Bearer ${OAUTH_TOKEN}`); + expect(JSON.stringify(usage)).not.toContain(INGEST_TOKEN); + expect(push.init.headers.Authorization).toBe(`Bearer ${INGEST_TOKEN}`); + expect(push.init.method).toBe("POST"); + expect(push.init.redirect).toBe("error"); + expect(push.init.signal).toBeInstanceOf(AbortSignal); + expect(JSON.stringify(push.init.headers)).not.toContain(OAUTH_TOKEN); + expect(push.init.body).not.toContain(OAUTH_TOKEN); + }); + + it("neither token appears in the snapshot nor in the output, on success or on hostile failures", async () => { + const scenarios = [ + routedFetch(), + routedFetch({ push: () => new Response(`denied ${INGEST_TOKEN} ${OAUTH_TOKEN}`, { status: 401 }) }), + routedFetch({ push: () => { throw new TypeError(`Headers.append: "Bearer ${INGEST_TOKEN}" is an invalid header value.`); } }), + routedFetch({ usage: () => new Response(`Authorization: Bearer ${OAUTH_TOKEN}`, { status: 500 }) }), + routedFetch({ push: () => { throw Object.assign(new Error(`connect to ${INGEST_TOKEN}`), { code: `E${INGEST_TOKEN}` }); } }), + ]; + for (const fetch of scenarios) { + fs.rmSync(path.join(tmpDir, "home"), { recursive: true, force: true }); + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const r = await runAgent({ home, fetch }); + expectNoLeak(`${r.stdout}\n${r.stderr}`); + expectNoLeak(pushCalls(fetch)[0].init.body); + expectNoLeak(fs.readFileSync(stateFilePath(defaultStateDir(home)), "utf-8")); + } + }); + + it("the real process output contains no fragment of either token", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials(Date.now() - 1000) }); + // Expired credentials (no Anthropic call), and a push target that refuses + // the connection: a real failing run, nothing leaves the machine. + const result = await execFileAsync(process.execPath, [PUSH_PATH], { + env: { + PATH: process.env.PATH, + HOME: home, + CLAUDE_USAGE_API_URL: "https://127.0.0.1:1", + CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, + NO_PROXY: "*", + no_proxy: "*", + }, + }).catch((e) => e); + expect(result.code).toBe(EXIT_PUSH); + expect(result.stderr).toMatch(/claude-usage: push failed: request error \(code=[A-Za-z0-9_]+\) limits=token_expired/); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + it.each([ + ["http:", { CLAUDE_USAGE_API_URL: "http://health.example.test" }, "must be https://"], + ["unset URL", { CLAUDE_USAGE_API_URL: "" }, "CLAUDE_USAGE_API_URL is not set"], + ["garbage URL", { CLAUDE_USAGE_API_URL: "::not a url::" }, "not a valid URL"], + ["unset token", { CLAUDE_USAGE_INGEST_TOKEN: "" }, "CLAUDE_USAGE_INGEST_TOKEN is not set"], + ["token with a trailing newline", { CLAUDE_USAGE_INGEST_TOKEN: `${INGEST_TOKEN}\n` }, "invalid character"], + ])("configuration error (%s) exits 1 before any scan or call", async (_label, envExtra, message) => { + const { home, stateFile } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + const openFile = vi.fn(); + const r = await runAgent({ home, fetch, env: agentEnv(envExtra), openFile }); + expect(r.code).toBe(EXIT_CONFIG); + expect(r.stderr).toContain(message); + expect(fetch.calls).toHaveLength(0); + expect(openFile).not.toHaveBeenCalled(); + expect(fs.existsSync(stateFile)).toBe(false); + expectNoLeak(`${r.stdout}${r.stderr}`); + }); + + it("readConfig appends /claude-usage to the base URL", () => { + expect(agentReadConfig(agentEnv({ CLAUDE_USAGE_API_URL: "https://h.example/" })).config.target).toBe( + "https://h.example/claude-usage", + ); + }); + + it(`a snapshot over ${MAX_BODY_BYTES} bytes logs payload_too_large, exits 2, sends nothing`, async () => { + const { home } = makeHome(); + const projectDir = path.join(home, ".claude", "projects", "big"); + fs.mkdirSync(projectDir, { recursive: true }); + // 30 days x 12 models with 64-character names and maximal counters: about + // 70 KB once serialized, every value still inside the contract. + const big = Number.MAX_SAFE_INTEGER; + const lines = []; + for (const date of windowDays("2026-10-04")) { + for (let m = 0; m < 12; m++) { + lines.push( + line({ + ts: `${date}T15:00:00Z`, + model: `m${String(m).padStart(2, "0")}-${"x".repeat(60)}`, + input: big, output: big, cacheCreation: big, cacheRead: big, + }), + ); + } + } + const file = path.join(projectDir, "s.jsonl"); + fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); + setMtime(file, "2026-10-04T15:30:00Z"); + + const fetch = routedFetch(); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toMatch(/payload_too_large bytes=\d+ max=65536/); + expect(Number(/bytes=(\d+)/.exec(r.stderr)[1])).toBeGreaterThan(MAX_BODY_BYTES); + expect(fetch.calls).toHaveLength(0); + }); + + it("one push attempt only: a failure is not retried", async () => { + const { home } = makeHome({ fixtures: true }); + const fetch = routedFetch({ push: () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }); } }); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toContain("push failed: request error (code=ECONNRESET)"); + expect(pushCalls(fetch)).toHaveLength(1); + }); + + it("a 2xx answer exits 0 with a one-line summary", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const r = await runAgent({ home, fetch: routedFetch() }); + expect(r.code).toBe(EXIT_OK); + expect(r.stdout).toMatch(/^claude-usage: ok status=204 limits=ok bytes=\d+$/); + expect(r.stderr).toBe(""); + }); + + it("--dry-run prints the snapshot, needs no configuration, pushes nothing and leaves the cache alone", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + const fetch = routedFetch(); + const r = await runAgent({ home, fetch, env: { CLAUDE_USAGE_LIMITS: "off" }, argv: ["--dry-run"] }); + expect(r.code).toBe(EXIT_OK); + const snap = JSON.parse(r.stdout); + expect(snap.days).toHaveLength(30); + expect(snap.limits).toEqual({ status: "disabled", fetchedAt: null, items: [] }); + expect(fetch.calls).toHaveLength(0); + expect(fs.existsSync(stateFile)).toBe(false); + }); + + it("the agent modules load nothing but node built-ins and their siblings", () => { + for (const name of ["limits.js", "state.js", "push-claude-usage.js"]) { + const src = fs.readFileSync(path.join(AGENT_DIR, name), "utf-8"); + for (const [, mod] of src.matchAll(/require\("([^"]+)"\)/g)) { + expect(mod.startsWith("node:") || mod.startsWith("./"), `${name} requires ${mod}`).toBe(true); + } + } + }); +}); + +// --- run-claude-usage.sh ---------------------------------------------------------- + +describe("run-claude-usage.sh", () => { + function writeEnvFile(home, lines, mode = 0o600) { + const file = path.join(home, ".config", "maximus-claude-usage.env"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode }); + fs.chmodSync(file, mode); + return file; + } + + // A stand-in for node: records that it ran, then holds the lock a while. + function writeStubNode(home, seconds) { + const stub = path.join(home, "stub-node"); + fs.writeFileSync(stub, `#!/bin/sh\necho run >> "$HOME/runs"\nsleep ${seconds}\n`, { mode: 0o755 }); + return stub; + } + + function runs(home) { + const file = path.join(home, "runs"); + return fs.existsSync(file) ? fs.readFileSync(file, "utf-8").trim().split("\n").length : 0; + } + + function wrapperEnv(home) { + return { PATH: process.env.PATH, HOME: home }; + } + + test("two runs in parallel: the second exits 0 without working", async () => { + const { home } = makeHome(); + const stub = writeStubNode(home, 2); + writeEnvFile(home, [ + `CLAUDE_USAGE_API_URL=${API_URL}`, + `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, + `NODE_BIN=${stub}`, + ]); + + const first = spawn(WRAPPER_PATH, [], { env: wrapperEnv(home), stdio: "ignore" }); + const firstDone = new Promise((resolve) => first.on("exit", (code) => resolve(code))); + const deadline = Date.now() + 5000; + while (runs(home) === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)); + expect(runs(home)).toBe(1); + + const started = Date.now(); + const second = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(Date.now() - started).toBeLessThan(1500); + expect(second.stdout).toContain("claude-usage: a run is already in progress, skipping"); + expect(second.stderr).toBe(""); + expect(runs(home)).toBe(1); + + expect(await firstDone).toBe(0); + // The lock is released with the first run: the next beat works again. + const stub0 = writeStubNode(home, 0); + expect(stub0).toBe(stub); + await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(runs(home)).toBe(2); + expect(fs.existsSync(path.join(home, ".local", "state", "maximus-claude-usage", "run.lock"))).toBe(true); + }, 15000); + + test("a dry run through the wrapper prints a valid snapshot and no token", async () => { + const { home } = makeHome({ fixtures: true }); + writeEnvFile(home, [ + `CLAUDE_USAGE_API_URL=${API_URL}`, + `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, + "CLAUDE_USAGE_LIMITS=off", + `NODE_BIN=${process.execPath}`, + ]); + const result = await execFileAsync(WRAPPER_PATH, ["--dry-run"], { env: wrapperEnv(home) }); + const snap = JSON.parse(result.stdout); + expect(snap.limits.status).toBe("disabled"); // CLAUDE_USAGE_LIMITS was exported + expect(sanitizeClaudeUsage(snap).snapshot).toEqual(snap); + expect(result.stderr).toBe(""); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + test.each([ + [["CLAUDE_USAGE_INGEST_TOKEN"], "CLAUDE_USAGE_API_URL is not set"], + [["CLAUDE_USAGE_API_URL"], "CLAUDE_USAGE_INGEST_TOKEN is not set"], + ])("stops when a variable is missing (%j)", async (present, message) => { + const { home } = makeHome(); + const values = { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN }; + writeEnvFile(home, present.map((k) => `${k}=${values[k]}`)); + const result = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e); + expect(result.code).toBe(1); + expect(result.stderr).toContain(message); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + test("stops when the env file is absent, and warns on a mode other than 600", async () => { + const { home } = makeHome(); + const missing = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain("claude-usage: env file not found:"); + + const stub = writeStubNode(home, 0); + writeEnvFile( + home, + [`CLAUDE_USAGE_API_URL=${API_URL}`, `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, `NODE_BIN=${stub}`], + 0o644, + ); + const loose = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(loose.stderr).toContain("is mode 644, expected 600"); + expect(runs(home)).toBe(1); + expectNoLeak(`${loose.stdout}${loose.stderr}`); + }); + + test("never enables shell tracing — `set -x` would echo the token into syslog", () => { + const script = fs.readFileSync(WRAPPER_PATH, "utf-8"); + const traced = script + .split("\n") + .filter((l) => !l.trim().startsWith("#")) + .filter((l) => /\bset\b[^#\n]*-[a-z]*x/.test(l)); + expect(traced).toEqual([]); + }); + + test("is executable, and takes a non-blocking flock under ~/.local/state", () => { + expect(fs.statSync(WRAPPER_PATH).mode & 0o111).toBeGreaterThan(0); + const script = fs.readFileSync(WRAPPER_PATH, "utf-8"); + expect(script).toContain('STATE_DIR="$HOME/.local/state/maximus-claude-usage"'); + expect(script).toMatch(/flock -n 9/); + }); +}); diff --git a/__tests__/fixtures/claude-usage/oauth-usage.json b/__tests__/fixtures/claude-usage/oauth-usage.json new file mode 100644 index 0000000..4168657 --- /dev/null +++ b/__tests__/fixtures/claude-usage/oauth-usage.json @@ -0,0 +1,53 @@ +{ + "five_hour": { "utilization": 1.0, "resets_at": "2026-09-27T21:30:00.512345+00:00" }, + "seven_day": { "utilization": 50.0, "resets_at": "2026-10-01T07:00:00.512345+00:00" }, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "seven_day_fable": null, + "iguana_necktie": null, + "extra_usage": null, + "opaque_a": null, + "opaque_b": null, + "opaque_c": null, + "opaque_d": null, + "opaque_e": null, + "opaque_f": null, + "opaque_g": null, + "opaque_h": null, + "opaque_i": null, + "opaque_j": null, + "opaque_k": null, + "opaque_l": null, + "opaque_m": null, + "opaque_n": null, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 1, + "severity": "normal", + "resets_at": "2026-09-27T21:30:00Z", + "scope": null, + "is_active": true + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 50, + "severity": "normal", + "resets_at": "2026-10-01T07:00:00Z", + "scope": null, + "is_active": true + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 63, + "severity": "normal", + "resets_at": "2026-10-01T07:00:00Z", + "scope": { "model": { "display_name": "Fable" } }, + "is_active": true + } + ] +} diff --git a/agent/claude-usage/limits.js b/agent/claude-usage/limits.js new file mode 100644 index 0000000..1ac23fc --- /dev/null +++ b/agent/claude-usage/limits.js @@ -0,0 +1,276 @@ +// Claude plan limits for the claude-usage agent (issue #50). +// +// One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access +// token Claude Code already stored in ~/.claude/.credentials.json. Returns the +// `limits` block of the POST /claude-usage contract (schemaVersion 1), minus +// the "last good values" fallback, which state.js owns. +// +// Properties that are load-bearing and easy to break: +// +// * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single +// use: refreshing here would log Claude Code out. An expired token means +// `token_expired` and no call at all. The file is only ever passed to +// fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js +// spies on every writing function of node:fs to prove it. +// * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a +// configurable push URL; the OAuth token must never follow it. The URL is +// a constant (no environment variable changes it) and redirects are +// refused (`redirect: "error"`), so the token cannot be bounced elsewhere. +// * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never +// from an error message or a response body: both can carry upstream text, +// and this value ends up in the admin dashboard. +// * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no +// Retry-After; a failed call waits for the next cron beat (15 min). + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const ANTHROPIC_BETA = "oauth-2025-04-20"; +const LIMITS_TIMEOUT_MS = 10000; +// Far beyond the ~2 KB the endpoint answers; past it the body is not parsed. +const MAX_RESPONSE_BYTES = 256 * 1024; +const MAX_ITEMS = 12; +const MAX_STRING = 128; + +const DETAILS = { + NO_CREDENTIALS: "no_credentials", + PARSE_ERROR: "parse_error", + NO_VERSION: "no_version", + TIMEOUT: "timeout", + NETWORK: "network", + BAD_RESPONSE: "bad_response", +}; + +// Same shape as the server sanitizer's timestamp check: a value outside it +// would be dropped there, so it is normalized (or nulled) here instead. +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/; + +function defaultCredentialsPath(home = os.homedir()) { + return path.join(home, ".claude", ".credentials.json"); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function failure(status, detail) { + const out = { status, fetchedAt: null, items: [] }; + if (detail) out.detail = detail; + return out; +} + +function cleanString(value) { + return typeof value === "string" ? value.slice(0, MAX_STRING) : null; +} + +// The contract wants an integer 0-100. Upstream sends a number that can be +// fractional (utilization) and could overshoot 100 once a limit is exceeded. +function cleanPercent(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.min(100, Math.max(0, Math.round(value))); +} + +// ISO string kept as sent when it already has the contract's shape; any other +// parseable string, or an epoch number (seconds or milliseconds), is +// re-rendered with toISOString; anything else is null. +function cleanResetsAt(value) { + if (typeof value === "string") { + if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) { + return value; + } + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); + } + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + const ms = value < 1e11 ? value * 1000 : value; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + return null; +} + +function item({ kind, group, percent, severity, resetsAt, model }) { + const out = { kind, group }; + if (percent !== undefined) out.percent = percent; + if (typeof severity === "string") out.severity = severity; + out.resetsAt = resetsAt; + out.model = model; + return out; +} + +// `limits[]` entries: { kind, group, percent, severity, resets_at, +// scope: { model: { display_name } } | null, is_active }. Built field by field, +// so no upstream key reaches the snapshot unless it is listed here. +function itemsFromLimits(list) { + const items = []; + for (const entry of list) { + if (items.length >= MAX_ITEMS) break; + if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue; + const scope = isPlainObject(entry.scope) ? entry.scope : null; + const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null; + items.push( + item({ + kind: entry.kind.slice(0, MAX_STRING), + group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING), + percent: cleanPercent(entry.percent), + severity: cleanString(entry.severity), + resetsAt: cleanResetsAt(entry.resets_at), + model, + }), + ); + } + return items; +} + +// Older shape: five_hour / seven_day { utilization, resets_at }. +function itemsFromWindows(body) { + const items = []; + const windows = [ + ["five_hour", "session", "session"], + ["seven_day", "weekly_all", "weekly"], + ]; + for (const [key, kind, group] of windows) { + const w = body[key]; + if (!isPlainObject(w)) continue; + const percent = cleanPercent(w.utilization); + if (percent === undefined) continue; + items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null })); + } + return items; +} + +function parseUsage(body) { + if (!isPlainObject(body)) return []; + if (Array.isArray(body.limits)) { + const items = itemsFromLimits(body.limits); + if (items.length) return items; + } + return itemsFromWindows(body); +} + +// Read the OAuth credentials. Never throws, never logs the content. +function readCredentials(credentialsPath, readFile) { + let raw; + try { + raw = readFile(credentialsPath); + } catch (err) { + if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS }; + return { detail: DETAILS.PARSE_ERROR }; + } + let parsed; + try { + // Claude Code rewrites this file in place: a half-written JSON is a normal + // occurrence, and the next beat will read the finished one. + parsed = JSON.parse(String(raw)); + } catch { + return { detail: DETAILS.PARSE_ERROR }; + } + const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null; + if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) { + return { detail: DETAILS.NO_CREDENTIALS }; + } + return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt }; +} + +async function readBody(res) { + const text = await res.text(); + if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large"); + return JSON.parse(text); +} + +function discardBody(res) { + try { + if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {}); + } catch { + // nothing to release + } +} + +/** + * Fetch the plan limits once. + * + * @param {object} opts + * @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read) + * @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default + * @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests + * @param {Function} [opts.fetch] fetch implementation — injectable for tests + * @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts) + * @param {number} [opts.now] epoch ms + * @param {number} [opts.timeoutMs] + * @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>} + */ +async function fetchLimits({ + env = process.env, + credentialsPath = defaultCredentialsPath(), + readFile = (file) => fs.readFileSync(file, "utf-8"), + fetch: fetchImpl = globalThis.fetch, + claudeCodeVersion = null, + now = Date.now(), + timeoutMs = LIMITS_TIMEOUT_MS, +} = {}) { + if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") { + return failure("disabled"); + } + + const creds = readCredentials(credentialsPath, readFile); + if (creds.detail) return failure("unavailable", creds.detail); + + // Never refreshed: the refresh token is single use and belongs to Claude + // Code. A missing expiresAt is left to the server to judge (a 401 then). + if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) { + return failure("token_expired"); + } + + if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) { + return failure("unavailable", DETAILS.NO_VERSION); + } + + let res; + try { + res = await fetchImpl(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${creds.accessToken}`, + "anthropic-beta": ANTHROPIC_BETA, + "User-Agent": `claude-code/${claudeCodeVersion}`, + Accept: "application/json", + }, + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const name = err && err.name; + return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK); + } + + const status = res && Number.isInteger(res.status) ? res.status : 0; + if (status < 200 || status > 299) { + discardBody(res); + return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE); + } + + let body; + try { + body = await readBody(res); + } catch (err) { + const name = err && err.name; + if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT); + return failure("unavailable", DETAILS.BAD_RESPONSE); + } + + const items = parseUsage(body); + if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE); + return { status: "ok", fetchedAt: new Date(now).toISOString(), items }; +} + +module.exports = { + fetchLimits, + parseUsage, + defaultCredentialsPath, + USAGE_URL, + ANTHROPIC_BETA, + LIMITS_TIMEOUT_MS, + DETAILS, +}; diff --git a/agent/claude-usage/push-claude-usage.js b/agent/claude-usage/push-claude-usage.js new file mode 100755 index 0000000..87756c0 --- /dev/null +++ b/agent/claude-usage/push-claude-usage.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +// claude-usage agent entry point (issue #50). Runs from the ThinkPad's +// crontab every 15 minutes through run-claude-usage.sh: +// +// 1. aggregate the local transcripts (aggregate.js), frozen days excluded; +// 2. read the plan limits once (limits.js); +// 3. update and write the local cache (state.js) BEFORE pushing, so a failed +// push never costs the next beat a full rescan; +// 4. assemble the schemaVersion 1 snapshot, measure it, push it once. +// +// Zero runtime dependencies (Node 22 global fetch). Same two rules as +// agent/push-metrics.js, for the same reason — the output goes to +// `logger -t claude-usage`, so into syslog and journald for good: +// +// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero. +// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the +// OAuth token is printed. Failures are rebuilt from fixed templates plus an +// HTTP status or a sanitized error code — never an error object, a message +// from the HTTP stack (undici quotes an invalid header value in it), the +// request options or a response body. + +const os = require("node:os"); +const path = require("node:path"); + +const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js"); +const { fetchLimits, defaultCredentialsPath } = require("./limits.js"); +const { + loadState, + saveState, + nextState, + limitsForSnapshot, + defaultStateDir, + stateFilePath, +} = require("./state.js"); + +const SCHEMA_VERSION = 1; +// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body +// would be refused with a 413, so it is not even sent. +const MAX_BODY_BYTES = 65536; +const PUSH_TIMEOUT_MS = 10000; + +const EXIT_OK = 0; +const EXIT_CONFIG = 1; +const EXIT_PUSH = 2; + +const PREFIX = "claude-usage:"; + +// A token is printable ASCII without spaces. Checked up front: fetch rejects +// an invalid header value with a message that QUOTES it. +const TOKEN_RE = /^[\x21-\x7e]+$/; +// What may be printed of an error: a system or undici code (ECONNREFUSED, +// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name +// (TimeoutError, TypeError). Anything else, whatever field it came from, is +// replaced by UNKNOWN rather than trusted. +const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/; +const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/; + +function safeCode(err) { + const codes = [err && err.cause && err.cause.code, err && err.code]; + for (const c of codes) { + if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c; + } + const name = err && err.name; + return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN"; +} + +function readConfig(env) { + const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim(); + const token = env.CLAUDE_USAGE_INGEST_TOKEN || ""; + if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" }; + if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" }; + if (!TOKEN_RE.test(token)) { + return { + error: + "CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)", + }; + } + let target; + try { + target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`); + } catch { + return { error: "CLAUDE_USAGE_API_URL is not a valid URL" }; + } + // The ingest token travels in this request: https only, no exception. + if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" }; + return { config: { target: target.href, token } }; +} + +function buildSnapshot({ now, aggregated, limits }) { + const snapshot = { + schemaVersion: SCHEMA_VERSION, + generatedAt: new Date(now).toISOString(), + }; + // Left out rather than null: the contract types it as a string, and the + // server drops a null there anyway. + if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion; + snapshot.timeZone = TIME_ZONE; + snapshot.limits = limits; + snapshot.days = aggregated.days; + snapshot.projects = aggregated.projects; + return snapshot; +} + +async function pushSnapshot({ target, token, body, fetchImpl }) { + let res; + try { + res = await fetchImpl(target, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body, + redirect: "error", + signal: AbortSignal.timeout(PUSH_TIMEOUT_MS), + }); + } catch (err) { + return { error: `push failed: request error (code=${safeCode(err)})` }; + } + // The response body is never read, never logged. + try { + if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {}); + } catch { + // nothing to release + } + const status = Number.isInteger(res.status) ? res.status : 0; + if (status >= 200 && status <= 299) return { status }; + return { error: `push failed: HTTP ${status}` }; +} + +/** + * @param {string[]} argv + * @param {object} env + * @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError + * @returns {Promise} exit code + */ +async function main(argv = process.argv.slice(2), env = process.env, deps = {}) { + const home = deps.home || os.homedir(); + const fetchImpl = deps.fetch || globalThis.fetch; + const now = deps.now !== undefined ? deps.now : Date.now(); + const log = deps.log || ((line) => console.log(line)); + const logError = deps.logError || ((line) => console.error(line)); + + const dryRun = argv.includes("--dry-run"); + + // Configuration first: a full scan costs seconds, no point spending them to + // then discover a typo in the env file. A dry run needs no configuration. + let config = null; + if (!dryRun) { + const result = readConfig(env); + if (result.error) { + logError(`${PREFIX} configuration error: ${result.error}`); + return EXIT_CONFIG; + } + config = result.config; + } + + const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home)); + const state = loadState(stateFile); + const today = torontoDay(now); + + const aggregated = await aggregate({ + root: deps.transcriptsRoot || path.join(home, ".claude", "projects"), + frozenDays: state.frozenDays, + today, + homedir: home, + ...(deps.openFile ? { openFile: deps.openFile } : {}), + }); + + const limits = await fetchLimits({ + env, + credentialsPath: deps.credentialsPath || defaultCredentialsPath(home), + ...(deps.readFile ? { readFile: deps.readFile } : {}), + fetch: fetchImpl, + claudeCodeVersion: aggregated.claudeCodeVersion, + now, + }); + + const updated = nextState(state, { openDays: aggregated.openDays, today, limits }); + // Cache first. A dry run is read-only: it shows what would be sent and + // leaves the cache exactly as the cron left it. + if (!dryRun) { + try { + saveState(stateFile, updated); + } catch (err) { + // Not fatal: the next beat rescans, which is slow but correct. + logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`); + } + } + + const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) }); + const body = JSON.stringify(snapshot); + const bytes = Buffer.byteLength(body, "utf-8"); + + if (dryRun) log(JSON.stringify(snapshot, null, 2)); + + if (bytes > MAX_BODY_BYTES) { + logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`); + return EXIT_PUSH; + } + if (dryRun) return EXIT_OK; + + const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl }); + const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`; + if (pushed.error) { + logError(`${PREFIX} ${pushed.error} ${summary}`); + return EXIT_PUSH; + } + log(`${PREFIX} ok status=${pushed.status} ${summary}`); + return EXIT_OK; +} + +if (require.main === module) { + main().then( + (code) => { + process.exitCode = code; + }, + (err) => { + // Nothing should land here. The code only, never the object or its + // message: either could carry a request option. + console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`); + process.exitCode = EXIT_PUSH; + }, + ); +} + +module.exports = { + main, + readConfig, + buildSnapshot, + pushSnapshot, + MAX_BODY_BYTES, + PUSH_TIMEOUT_MS, + EXIT_OK, + EXIT_CONFIG, + EXIT_PUSH, +}; diff --git a/agent/claude-usage/run-claude-usage.sh b/agent/claude-usage/run-claude-usage.sh new file mode 100755 index 0000000..86c0767 --- /dev/null +++ b/agent/claude-usage/run-claude-usage.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# +# Cron wrapper for push-claude-usage.js: take the lock, load the secrets, check +# the install, hand over to node. Every fifteen minutes, unattended. +# +# NEVER add `set -x`. This script is meant to be piped into `logger`, and the +# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight +# into /var/log/syslog and journald, where it would stay. Same reason nothing +# below ever echoes a variable that holds a secret: the checks print the NAME of +# what is missing, never its value. +# +# Suggested crontab line (see agent/README.md): +# */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +AGENT="$SCRIPT_DIR/push-claude-usage.js" + +# Overridable for testing only; cron uses the default. +ENV_FILE="${CLAUDE_USAGE_ENV_FILE:-$HOME/.config/maximus-claude-usage.env}" +STATE_DIR="$HOME/.local/state/maximus-claude-usage" +LOCK_FILE="$STATE_DIR/run.lock" + +if ! command -v flock >/dev/null 2>&1; then + echo "claude-usage: flock not found (util-linux)" >&2 + exit 1 +fi + +mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" + +# One run at a time. The first full scan of ~210 MB of transcripts can outlast +# the 15-minute beat; a second run would only redo the same work and race the +# state file. fd 9 stays open through `exec`, so node holds the lock until it +# exits. A beat that finds the lock taken is not an error: exit 0. +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "claude-usage: a run is already in progress, skipping" + exit 0 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "claude-usage: env file not found: $ENV_FILE" >&2 + exit 1 +fi + +# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is +# not worth silencing the whole agent over. +FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "") +if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then + echo "claude-usage: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2 +fi + +# shellcheck source=/dev/null +. "$ENV_FILE" + +if [ -z "${CLAUDE_USAGE_API_URL:-}" ]; then + echo "claude-usage: CLAUDE_USAGE_API_URL is not set in $ENV_FILE" >&2 + exit 1 +fi +if [ -z "${CLAUDE_USAGE_INGEST_TOKEN:-}" ]; then + echo "claude-usage: CLAUDE_USAGE_INGEST_TOKEN is not set in $ENV_FILE" >&2 + exit 1 +fi + +if [ ! -f "$AGENT" ]; then + echo "claude-usage: agent not found: $AGENT" >&2 + exit 1 +fi + +# cron's PATH is famously short (/usr/bin:/bin), and a node installed through +# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when +# `command -v node` comes up empty under cron but works in a login shell. +NODE_BIN="${NODE_BIN:-node}" +if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + echo "claude-usage: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2 + exit 1 +fi + +# CLAUDE_USAGE_LIMITS is optional (`off` skips the Anthropic call). Exported +# too, otherwise setting it in the env file silently does nothing. +export CLAUDE_USAGE_API_URL CLAUDE_USAGE_INGEST_TOKEN CLAUDE_USAGE_LIMITS + +exec "$NODE_BIN" "$AGENT" "$@" diff --git a/agent/claude-usage/state.js b/agent/claude-usage/state.js new file mode 100644 index 0000000..7a83ddb --- /dev/null +++ b/agent/claude-usage/state.js @@ -0,0 +1,163 @@ +// 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, +};