The CI runs the tests in node:22-alpine, where 9 of the 12 cases failed:
no ssh on PATH, and busybox has neither `find -printf` nor
`date -d "... days"`. The newest dated directory is now found by a loop
over the glob, and the rotation cutoff by epoch arithmetic, which GNU
date and busybox both read. The tests add a fake ssh, and a fake flock
where the host has none.
The rotation_failed case failed the pass through a read-only directory,
which root ignores: it now uses a fake rm.
Review suggestions: the rsync argv must put --include=*/ before
--exclude=* (without it nothing crosses and the pass still says ok), and
a case runs the real rsync through the fake ssh when rsync is present.
A pass whose newest directory is not today's logs a warning.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POSIX sh agent run by a systemd user timer (Persistent=true, retries on
failure): rsync over the classic sshd on 100.86.30.21:2222 with a pinned
host key, .age files only, 30-day rotation that fails loudly, and a
report to POST /backups/pull when the ingest token is configured (sent
through a header file, never argv). Never decrypts.
Refs maximus/la-compagnie-maximus#220
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Review of #56: making ENOENT an empty scan on every directory, not only the
root, left the suite green. A vanished subdirectory is now a pinned read
error. The header also names the other known limit of the midnight fix:
the mtime threshold may skip a subagent transcript holding the only copy
of a straddling request's earlier line.
Refs #54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
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 <noreply@anthropic.com>
The threshold came from the most recent frozen key: a frozen day in the
future, or a gap in the frozen run, pushed it past open days whose files
were then never read, and the agent would have frozen them empty for good.
It is now the oldest open day of the window (the same day as before for the
agent's contiguous frozen prefix).
Project, model and day maps are now Object.create(null): a project named
__proto__ or constructor wrote NaN counters onto Object.prototype and
dropped out of the top.
Resolves#49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pure, dependency-free module for the claude-usage agent. It walks
~/.claude/projects/**/*.jsonl (subagent transcripts included), keeps
assistant lines with message.usage, drops <synthetic> and truncated
lines, deduplicates on (message.id, requestId) across all files at the
largest output_tokens, and buckets by America/Toronto day computed as
strings only (DST-safe).
Returns { openDays, days, projects, claudeCodeVersion }: the recomputed
non-frozen days, the merged 30-day series (at most 12 models per day,
the rest folded into "other"), the top 8 projects by work tokens plus
"(autres)", and the version of the newest line. Files whose mtime day
is before the day after the most recent frozen day are never opened.
Resolves#49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the review of the workstation monitoring stack (#18-#23):
- loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The
admin card joins them, so a shorter array rendered a partial line and a
longer one was silently truncated; both are now a 400, and a stored
snapshot off that format degrades to neverSeen on read.
- sweepStaleTempFiles() runs at startup, before listen, and removes the
`.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind,
once older than an hour. Exact name format only, regular files only
(lstat), best effort: a missing HOSTS_DIR stays silent, other failures
are logged and never stop the server.
- Document that cleanString keeps control characters on purpose (safety
rests on the consumer escaping at render time), and that frozenRequest
does not freeze `new Date()`, which stamps receivedAt on POST.
Fixes#25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of
which changed behaviour, all of which weakened a guarantee the stack was
supposed to provide.
1. auth.test.js carried "any new route must be added here" but /hosts was
never added when #13 introduced it, so no test asserted GET /hosts -> 401
on a missing header. The drift class the file exists to catch slipped on
its first outing. ROUTES now covers /hosts, and a dedicated block pins
both directions of the token separation: a read token cannot write, an
ingest token cannot read.
2. ingestOversized() folded any transport error into 413, so the four
oversized-body tests would have stayed green if the server had stopped
writing the status and merely killed the socket - on the one path where
"a status, not a dead socket" is the whole client contract. Transport
errors are now surfaced instead of swallowed.
3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by
run-push.sh, so setting it in the documented env file did nothing. Now
exported and documented.
Also drops a claim from agent/README.md that the review proved false: the
ingest/read token split buys no containment on the ThinkPad, which already
stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop
compromises both, so they rotate together.
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.
run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.
50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.
agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.
Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.
Resolves#15