- aggregate: frozen days are dropped after deduplication, not before. A request straddling midnight whose best line sat on a day frozen since was counted again on the open day. On equal output the earliest line now wins (it was the first one read), so the choice no longer depends on which files a beat reads. - aggregate: unreadable directories, failed stats (ENOENT included) and stream errors are counted in `readErrors`; a missing root is still an empty scan. The beat then freezes no new day (nextState freeze: false), still pushes, and logs `read_errors=N` with no path. - limits: the response is read chunk by chunk and counted in bytes; past 256 KiB the stream is cancelled instead of being read in full and measured in characters. - state: a test now covers the chmodSync of a leftover temp file. Each fix was checked by mutation (13 mutations, 13 red). Refs #54 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
8.5 KiB
JavaScript
Executable file
244 lines
8.5 KiB
JavaScript
Executable file
#!/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<number>} 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,
|
|
});
|
|
|
|
// An incomplete scan freezes nothing. Only a count is printed: a path
|
|
// would put a project name in syslog for good.
|
|
const complete = aggregated.readErrors === 0;
|
|
if (!complete) {
|
|
logError(`${PREFIX} warning: read_errors=${aggregated.readErrors}, no day frozen this beat`);
|
|
}
|
|
const updated = nextState(state, { openDays: aggregated.openDays, today, limits, freeze: complete });
|
|
// 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,
|
|
};
|