diff --git a/CLAUDE.md b/CLAUDE.md index 4be7f43..b895d7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime. - `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel. - `POST /hosts/` — **premiere des deux surfaces d'ecriture de l'API** (figees par `__tests__/writes.test.js`, sur les chemins nominaux et degrades qu'il exerce). Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `` bien forme mais hors allowlist / 404 `` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`. - `POST /claude-usage` — **seconde surface d'ecriture** (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contrat `schemaVersion: 1` partage avec le dashboard (`la-compagnie-maximus`, `spec-plan-suivi-claude.md`). Auth par `CLAUDE_USAGE_INGEST_TOKEN`, son propre token : ni `HOSTS_INGEST_TOKEN` ni `HEALTH_TOKEN` n'y passent, et lui n'ouvre ni `POST /hosts/` ni aucune lecture. Le corps passe par `sanitizeClaudeUsage()`, module pur `claude-usage.js` (l'agent teste son snapshot contre le vrai sanitizer) : reconstruction par liste blanche, champ inconnu jete, **valeur invalide jetee (cle omise), jamais forcee** — nombres `Number.isSafeInteger` >= 0, `percent` 0-100, `limits.status` dans l'enum `ok|token_expired|http_error|unavailable|disabled`, `limits.detail` `^[a-z0-9_]{1,32}$` (code, jamais texte libre : un corps de reponse amont ne doit pas voyager jusqu'au dashboard), dates ISO completes avec decalage, `date` de jour `^\d{4}-\d{2}-\d{2}$` et jour reel, cles de `models` `^[a-z0-9][a-z0-9.\-]{0,63}$`, autres chaines tronquees a 128. Listes bornees : `days` <= 31 (au-dela, les 31 plus recents, le contrat les ordonnant du plus ancien a aujourd'hui), `models` <= 12 par jour, `limits.items` <= 12, `projects` <= 9 (au-dela, les premiers). Seuls deux cas refusent le corps entier : pas un objet JSON, ou `schemaVersion` different de 1. Codes : 204 / 400 JSON illisible, pas un objet ou `schemaVersion` != 1 / 401 / 413 corps > 64 Kio (65 536 octets recus) / 503 `CLAUDE_USAGE_INGEST_TOKEN` non configure. `receivedAt` pose par le serveur, ecriture atomique (temporaire `....tmp` dans le meme dossier, mode 600, `renameSync`), patron de `writeHostSnapshot`. -- `GET /claude-usage` — `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou 200 `{ status: "no_data" }` si le fichier est absent ou illisible (corrompu, hors contrat, sans `receivedAt` exploitable). Le fichier est **re-sanitise** a la lecture (`sanitizeClaudeUsage` est idempotent). `stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS`, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel. +- `GET /claude-usage` — `{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }`, ou 200 `{ status: "no_data" }` si le fichier est absent ou illisible (corrompu, hors contrat, sans `receivedAt` en date ISO complete avec decalage, meme regle que `GET /hosts` depuis #54). Le fichier est **re-sanitise** a la lecture (`sanitizeClaudeUsage` est idempotent). `stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS`, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel. ## Routage et auth @@ -68,15 +68,15 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests - **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 611 cas - - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (119 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/` toujours 413 a 4 097 ; ecriture atomique en mode 600 ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) +- `npm test` (vitest) — 634 cas + - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (123 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/` toujours 413 a 4 097 ; ecriture atomique en mode 600, et nettoyage du temporaire quand le `rename` echoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture, `receivedAt` compris s'il n'est pas une date ISO complete avec decalage (#54 : `"1"`, date seule, heure sans decalage) ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST) - `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence) - `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur) - - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`) + - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (177 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture (dont un `receivedAt` hors ISO complet, #54), temporaire supprime quand le `rename` echoue (#54), **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`) - `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**) - - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (111 cas). Agregation des transcripts, #49 (43 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype`. Agent, #50 (68 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600 ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler + - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (126 cas). Agregation des transcripts, #49 (52 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype` ; #54 : requete a cheval sur minuit comptee une seule fois (jours figes ecartes apres la deduplication, egalite d'`output` gagnee par la ligne la plus ancienne quel que soit l'ordre de lecture), erreurs de lecture comptees (`readdir`, `ENOENT` d'un sous-dossier compris, `stat` y compris `ENOENT`, flux, ouverture), racine absente non comptee. Agent, #50 (74 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600, temporaire laisse en 644 ramene a 600 (#54) ; passage avec erreur de lecture : aucun jour fige, snapshot envoye, `read_errors=N` sans chemin, le passage suivant fige (#54) ; reponse de l'endpoint comptee en octets pendant la lecture, 256 Kio pile acceptes, + 1 octet en caracteres de 2 octets refuse, flux sans fin annule (#54) ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler - `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (48 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 45 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`) - `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage` (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture) (14 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir) - Runtime reste 0-dep ; vitest en devDep uniquement @@ -88,7 +88,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - Pas d'Express — HTTP natif Node.js uniquement - Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais. - `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env. -- `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`). +- `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`). Un jour se fige a J+2 et n'est plus jamais recalcule : un passage qui a rencontre une erreur de lecture (`readErrors > 0`) ne fige donc aucun nouveau jour et journalise `read_errors=N`, sans chemin (#54) ; et les jours figes sont ecartes **apres** la deduplication, jamais avant, sinon une requete a cheval sur minuit est comptee deux fois. - `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`. - L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/` ecrit dans `HOSTS_DIR`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas. - L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `....tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json...tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle. diff --git a/__tests__/agent-claude-usage.test.js b/__tests__/agent-claude-usage.test.js index 5ad23fb..419fc92 100644 --- a/__tests__/agent-claude-usage.test.js +++ b/__tests__/agent-claude-usage.test.js @@ -644,6 +644,75 @@ describe("frozen days", () => { expect(r.projects).toEqual([{ name: "alpha", work: 0, cacheRead: 1, requests: 1 }]); expect(Object.keys(r.openDays)).toHaveLength(29); }); + + // #54: a request whose lines straddle midnight. The day freezes at D+2, so + // the beat that froze it saw every line of the request and gave it to the + // day of its best line. Later beats must make the same choice, not count + // the request again on the open day once the frozen line is filtered out. + // Toronto is UTC-4 here: 03:59Z is Oct 1 23:59, 04:01Z is Oct 2 00:01. + it("a request straddling midnight whose best line sits on a frozen day is not counted again on the open day", async () => { + writeTranscript( + "a/straddle.jsonl", + [ + line({ ts: "2026-10-02T03:59:00Z", id: "msg_mid", req: "req_mid", output: 5 }), + line({ ts: "2026-10-02T04:01:00Z", id: "msg_mid", req: "req_mid", output: 5 }), + ], + "2026-10-02T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN }); + expect(dayOf(r, "2026-10-02").models).toEqual({}); + expect(r.openDays["2026-10-02"].models).toEqual({}); + // The frozen day is served as it was counted, untouched. + expect(dayOf(r, "2026-10-01").models["claude-opus-5-5"].requests).toBe(7); + }); + + it("a request straddling midnight whose best line sits on the open day is counted once there", async () => { + writeTranscript( + "a/straddle.jsonl", + [ + line({ ts: "2026-10-02T03:59:00Z", id: "msg_mid", req: "req_mid", output: 1 }), + line({ ts: "2026-10-02T04:01:00Z", id: "msg_mid", req: "req_mid", output: 5 }), + ], + "2026-10-02T12:00:00Z", + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME, frozenDays: FROZEN }); + expect(dayOf(r, "2026-10-02").models["claude-opus-5-5"]).toEqual({ + input: 1, + output: 5, + cacheCreation: 0, + cacheRead: 0, + requests: 1, + }); + }); + + it("on equal output the earliest line wins, whatever the order the files are read in", async () => { + // Sorted read order puts the later line first. The beat that freezes a day + // and the beats after it do not read the same files (the threshold moves), + // so the winner must not depend on the read order. + const later = writeTranscript( + "a/a-first.jsonl", + [line({ ts: "2026-10-02T04:01:00Z", id: "msg_tie", req: "req_tie", output: 5 })], + "2026-10-02T12:00:00Z", + ); + const earlier = writeTranscript( + "a/b-second.jsonl", + [line({ ts: "2026-10-02T03:59:00Z", id: "msg_tie", req: "req_tie", output: 5 })], + "2026-10-02T12:00:00Z", + ); + const opened = []; + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + openFile: (f, o) => { + opened.push(f); + return fs.createReadStream(f, o); + }, + }); + expect(opened).toEqual([later, earlier]); + expect(dayOf(r, "2026-10-01").models["claude-opus-5-5"].requests).toBe(1); + expect(dayOf(r, "2026-10-02").models).toEqual({}); + }); }); // --- Robustness ----------------------------------------------------------------- @@ -684,6 +753,86 @@ describe("robustness", () => { }); }); + // #54: an unreadable transcript, directory or stat is counted, so that the + // agent freezes nothing on that beat. + it("counts a stream error as a read error", async () => { + writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const bad = writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + openFile: (f, o) => fs.createReadStream(f === bad ? path.join(tmpDir, "does-not-exist") : f, o), + }); + expect(r.readErrors).toBe(1); + }); + + it("counts an opener that throws as a read error", async () => { + writeTranscript("p/bad.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const r = await aggregate({ + root: tmpDir, + today: "2026-10-04", + homedir: HOME, + openFile: () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }, + }); + expect(r.readErrors).toBe(1); + }); + + it("counts an unreadable subdirectory as a read error, and skips its files", async () => { + writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z", output: 4 })], "2026-10-04T12:00:00Z"); + writeTranscript("q/hidden.jsonl", [line({ ts: "2026-10-03T15:00:00Z", output: 9 })], "2026-10-04T12:00:00Z"); + const badDir = path.join(tmpDir, "q"); + // A spy rather than chmod 000: the CI runs as root, which reads anyway. + const realReaddir = fs.promises.readdir; + vi.spyOn(fs.promises, "readdir").mockImplementation((dir, opts) => + dir === badDir + ? Promise.reject(Object.assign(new Error("denied"), { code: "EACCES" })) + : realReaddir.call(fs.promises, dir, opts), + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(r.readErrors).toBe(1); + expect(dayOf(r, "2026-10-03").models["claude-opus-5-5"].output).toBe(4); + }); + + it("counts a subdirectory gone between the walk and its listing (ENOENT) as a read error", async () => { + // ENOENT is only an empty scan on the root itself: a project directory that + // vanishes mid-walk may have held lines of a day about to be frozen. + writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + writeTranscript("q/gone.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const goneDir = path.join(tmpDir, "q"); + const realReaddir = fs.promises.readdir; + vi.spyOn(fs.promises, "readdir").mockImplementation((dir, opts) => + dir === goneDir + ? Promise.reject(Object.assign(new Error("gone"), { code: "ENOENT" })) + : realReaddir.call(fs.promises, dir, opts), + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(r.readErrors).toBe(1); + }); + + it("counts a failed stat as a read error, ENOENT included", async () => { + writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const gone = writeTranscript("p/gone.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const realStat = fs.promises.stat; + vi.spyOn(fs.promises, "stat").mockImplementation((file, opts) => + file === gone + ? Promise.reject(Object.assign(new Error("gone"), { code: "ENOENT" })) + : realStat.call(fs.promises, file, opts), + ); + const r = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(r.readErrors).toBe(1); + }); + + it("a missing root is an empty scan, not a read error; a clean scan has none", async () => { + const missing = await aggregate({ root: path.join(tmpDir, "absent"), today: "2026-10-04", homedir: HOME }); + expect(missing.readErrors).toBe(0); + writeTranscript("p/good.jsonl", [line({ ts: "2026-10-03T15:00:00Z" })], "2026-10-04T12:00:00Z"); + const clean = await aggregate({ root: tmpDir, today: "2026-10-04", homedir: HOME }); + expect(clean.readErrors).toBe(0); + }); + it("folds a model name outside the server key pattern into `other`", async () => { writeTranscript( "p/s.jsonl", @@ -995,6 +1144,61 @@ describe("limits — the endpoint's answer", () => { }); }); +describe("limits — the response size is counted in bytes, while reading (#54)", () => { + const MAX = 256 * 1024; + const limitsOpts = (fetch) => ({ + env: {}, + credentialsPath: "/unused", + readFile: () => JSON.stringify(goodCredentials()), + claudeCodeVersion: "2.1.283", + now: NOW, + fetch, + }); + // A valid answer padded to exactly `bytes` UTF-8 bytes with `pad`. + function padded(bytes, pad) { + const base = JSON.stringify({ ...usageFixture(), pad: "" }); + let room = bytes - Buffer.byteLength(base); + const unit = Buffer.byteLength(pad); + let fill = "a".repeat(room % unit); + fill += pad.repeat(Math.floor(room / unit)); + const body = JSON.stringify({ ...usageFixture(), pad: fill }); + expect(Buffer.byteLength(body)).toBe(bytes); + return body; + } + + it("accepts a body of exactly 256 KiB", async () => { + const body = padded(MAX, "a"); + const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(body, { status: 200 })))); + expect(r.status).toBe("ok"); + }); + + it("refuses 256 KiB + 1 byte of two-byte characters, fewer than 256 Ki characters", async () => { + const body = padded(MAX + 1, "\u00e9"); + expect(body.length).toBeLessThan(MAX); + const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(body, { status: 200 })))); + expect(r).toEqual({ status: "unavailable", detail: DETAILS.BAD_RESPONSE, fetchedAt: null, items: [] }); + }); + + it("stops reading an endless body and cancels it", async () => { + let pulls = 0; + let cancelled = false; + const chunk = new Uint8Array(64 * 1024).fill(0x20); + const stream = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + const r = await fetchLimits(limitsOpts(makeFetch(() => new Response(stream, { status: 200 })))); + expect(r).toEqual({ status: "unavailable", detail: DETAILS.BAD_RESPONSE, fetchedAt: null, items: [] }); + expect(cancelled).toBe(true); + expect(pulls).toBeLessThan(10); // 5 chunks cross the ceiling + }); +}); + // --- limits: when no call is made ---------------------------------------------- describe("limits — cases that never call the endpoint", () => { @@ -1252,6 +1456,24 @@ describe("state — local cache", () => { expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-08-30", "2026-09-20"]); }); + it("with freeze: false, keeps the frozen days (pruned) and freezes no new one", () => { + const state = { + ...emptyState(), + frozenDays: { + "2026-08-29": { models: {}, projects: {} }, // pruned all the same + "2026-09-20": { models: {}, projects: {} }, + }, + }; + const openDays = { + "2026-10-01": { models: { m: { input: 1 } }, projects: {} }, + "2026-10-03": { models: {}, projects: {} }, + }; + const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] }; + const s = nextState(state, { openDays, today: "2026-10-04", limits: ok, freeze: false }); + expect(Object.keys(s.frozenDays)).toEqual(["2026-09-20"]); + expect(s.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items }); + }); + 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 }); @@ -1292,6 +1514,19 @@ describe("state — local cache", () => { expect(loadState(file).frozenDays).toEqual({ "2026-10-01": { models: {}, projects: {} } }); }); + it("a temp file left at mode 644 by a killed run still ends in a mode 600 cache", () => { + // writeFileSync only applies `mode` when it creates the file (#54). + const file = path.join(tmpDir, "state", "state.json"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, "leftover"); + fs.chmodSync(tmp, 0o644); // explicit: the umask could already give 600 + expect(fs.statSync(tmp).mode & 0o777).toBe(0o644); + saveState(file, emptyState()); + expect(fs.existsSync(tmp)).toBe(false); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + 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"); }); @@ -1353,6 +1588,42 @@ describe("push-claude-usage — one beat", () => { expect(snap2.projects).toEqual(snap1.projects); }); + it("a beat with a read error freezes no day, still pushes, logs a count only; the next clean beat freezes", async () => { + const { home, stateFile } = makeHome({ credentials: undefined }); + const projectDir = path.join(home, ".claude", "projects", "-home-tester-claude-code-alpha"); + fs.mkdirSync(projectDir, { recursive: true }); + const write = (name, lines) => { + const file = path.join(projectDir, name); + fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); + setMtime(file, "2026-10-04T15:30:00Z"); + return file; + }; + write("good.jsonl", [line({ ts: "2026-10-01T15:00:00Z", output: 3 })]); + const bad = write("secret-name.jsonl", [line({ ts: "2026-10-01T15:00:00Z", output: 8 })]); + const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" }); + + const fetch1 = routedFetch(); + const r1 = await runAgent({ + home, fetch: fetch1, env, + openFile: (f, o) => fs.createReadStream(f === bad ? path.join(projectDir, "missing.jsonl") : f, o), + }); + expect(r1.code).toBe(EXIT_OK); + expect(pushCalls(fetch1)).toHaveLength(1); + expect(loadState(stateFile).frozenDays).toEqual({}); + expect(r1.stderr).toContain("read_errors=1"); + expect(r1.stderr).not.toContain("secret-name"); + expect(r1.stderr).not.toContain(home); + expect(r1.stdout).not.toContain("secret-name"); + + const fetch2 = routedFetch(); + const r2 = await runAgent({ home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000 }); + expect(r2.code).toBe(EXIT_OK); + expect(r2.stderr).not.toContain("read_errors"); + const frozen = loadState(stateFile).frozenDays; + expect(Object.keys(frozen)).toHaveLength(28); + expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(11); + }); + 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 }); diff --git a/__tests__/claude-usage.test.js b/__tests__/claude-usage.test.js index ae50546..ec08e58 100644 --- a/__tests__/claude-usage.test.js +++ b/__tests__/claude-usage.test.js @@ -736,6 +736,23 @@ describe("POST /claude-usage — payload", () => { expect(JSON.parse(fs.readFileSync(usagePath, "utf-8")).claudeCodeVersion).toBe("2.1.284"); }); + test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => { + // The target is a non-empty directory: mkdir and the temp write succeed, + // renameSync fails. Only the cleanup in writeClaudeUsage() removes the + // temp file (#54: the mkdir case below never reaches it). + fs.mkdirSync(path.join(usagePath, "occupied"), { recursive: true }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const res = await push(); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Internal error"); + } finally { + error.mockRestore(); + } + expect(fs.readdirSync(path.dirname(usagePath))).toEqual(["claude-usage.json"]); + expect(fs.readdirSync(usagePath)).toEqual(["occupied"]); + }); + test("500 when the snapshot cannot be written, and no temp file is left", async () => { // The parent of the file is a regular file: mkdir fails, nothing lands. const blocker = path.join(tmpDir, "blocker"); @@ -842,6 +859,11 @@ describe("GET /claude-usage", () => { ["another schemaVersion", JSON.stringify({ ...validUsage(), schemaVersion: 2, receivedAt: "2026-09-27T19:00:00.000Z" })], ["no receivedAt", JSON.stringify(validUsage())], ["an unusable receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "never" })], + // new Date() reads all three; the server only ever stamps a full ISO + // timestamp with an offset, so anything else was not written by it (#54). + ["a receivedAt of \"1\"", JSON.stringify({ ...validUsage(), receivedAt: "1" })], + ["a date-only receivedAt", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07" })], + ["a receivedAt without an offset", JSON.stringify({ ...validUsage(), receivedAt: "2026-10-07T12:00:00" })], ])("on %s", async (_label, raw) => { writeStored(raw); const res = await read(); diff --git a/__tests__/hosts.test.js b/__tests__/hosts.test.js index 8ed3212..a0bf8c8 100644 --- a/__tests__/hosts.test.js +++ b/__tests__/hosts.test.js @@ -869,6 +869,19 @@ describe("POST /hosts/ — what actually lands on disk", () => { expect(status).toBe(500); expect(body.error).toBe("Internal error"); }); + + test("500 when the rename fails after the temp file is written, and the temp file is removed", async () => { + // The target is a non-empty directory: mkdir and the temp write succeed, + // renameSync fails. Only the cleanup in writeHostSnapshot() removes the + // temp file (#54: the case above fails at mkdir, before it exists). + fs.mkdirSync(path.join(hostsDir, "thinkpad.json", "occupied"), { recursive: true }); + + const { status, body } = await ingest("/hosts/thinkpad"); + expect(status).toBe(500); + expect(body.error).toBe("Internal error"); + expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]); + expect(fs.readdirSync(path.join(hostsDir, "thinkpad.json"))).toEqual(["occupied"]); + }); }); describe("GET /hosts — freshness boundary", () => { @@ -1010,6 +1023,14 @@ describe("GET /hosts — listing semantics", () => { "a receivedAt that is not a date", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "yesterday" }), ], + // new Date() reads the next three; the server only ever stamps a full ISO + // timestamp with an offset, so anything else was not written by it (#54). + ['a receivedAt of "1"', JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "1" })], + ["a date-only receivedAt", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "2026-10-07" })], + [ + "a receivedAt without an offset", + JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: "2026-10-07T12:00:00" }), + ], [ "a receivedAt that is not a string", JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: 1700000000000 }), diff --git a/agent/README.md b/agent/README.md index 4e6ace7..8e2084d 100644 --- a/agent/README.md +++ b/agent/README.md @@ -298,3 +298,21 @@ A beat that finds the previous one still running (`flock` on The limits failing is **not** an exit code: the snapshot is still pushed, with `limits.status` saying why. + +### Unreadable transcripts + +A day is frozen in `state.json` two days after it ends, and never recomputed +afterwards. A beat that cannot read part of `~/.claude/projects` (a directory it +cannot list, a transcript removed between the walk and its `stat`, a stream +error) therefore freezes **no** new day: it still pushes its snapshot, exits as +usual, and logs only a count: + +``` +claude-usage: warning: read_errors=N, no day frozen this beat +``` + +The next clean beat freezes the days that were waiting. A file that stays +unreadable (left at `chmod 000`, for instance) blocks freezing on every beat: +each run then rescans the whole 30-day window, slower but correct, and this +log line is the only sign. Fix the file's permissions. To force a full +recount after the fact, `rm ~/.local/state/maximus-claude-usage/state.json`. diff --git a/agent/claude-usage/aggregate.js b/agent/claude-usage/aggregate.js index d0bf34e..98dde50 100644 --- a/agent/claude-usage/aggregate.js +++ b/agent/claude-usage/aggregate.js @@ -16,7 +16,22 @@ // times (one line per content block, and again in subagent transcripts), // with identical input/cache counters and a growing output_tokens. Lines // are deduplicated on (message.id, requestId) across ALL files, keeping the -// line with the largest output_tokens. +// line with the largest output_tokens; on equal output, the earliest line, +// so the choice never depends on the order the files are read in. +// * FROZEN DAYS ARE DROPPED AFTER DEDUPLICATION, NOT BEFORE. A request whose +// lines straddle midnight belongs to the day of its best line. The beat +// that froze a day saw all its lines (a day freezes at D+2) and made that +// choice; filtering the frozen day's lines first would leave the later +// line alone and count the request a second time on the open day (#54). +// Known limits: the window start is still filtered per line, so a request +// straddling the window's first midnight lands on the window's first day; +// and the mtime threshold may skip a subagent transcript holding the only +// copy of a straddling request's earlier line, in which case a later beat +// sees fewer lines of it than the beat that froze the day. +// * READ ERRORS ARE COUNTED. An unreadable directory, a failed stat or a +// stream error skips that input but is reported in `readErrors`: the +// agent then freezes nothing on that beat, rather than freezing a day +// with lines missing (#54). A missing root is an empty scan, not an error. // * THE SCAN THRESHOLD. Frozen days are never recomputed. A file whose mtime // (Toronto day) is before the oldest open day of the window cannot hold a // line on an open day, so it is not even opened: this is what keeps a run @@ -225,8 +240,9 @@ function normalizeDay(day) { // --- Transcript scan -------------------------------------------------------- // Every *.jsonl under root, recursively (subagents/ included). Symlinks are -// neither followed nor read. A missing root is an empty scan, not an error. -async function listTranscripts(root) { +// neither followed nor read. A missing root is an empty scan, not an error; +// any other directory that cannot be listed is reported through onError. +async function listTranscripts(root, onError = () => {}) { const out = []; const stack = [root]; while (stack.length) { @@ -234,7 +250,8 @@ async function listTranscripts(root) { let entries; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); - } catch { + } catch (err) { + if (!(dir === root && err && err.code === "ENOENT")) onError(); continue; } for (const e of entries) { @@ -247,33 +264,41 @@ async function listTranscripts(root) { return out; } -// Calls onLine for each line of the file. Resolves once the file is consumed; -// a read error (file removed mid-run, permission) resolves too: one unreadable -// transcript must not sink the whole snapshot. +// Calls onLine for each line of the file. Resolves once the file is consumed, +// with true when it was read to the end; a read error (file removed mid-run, +// permission) resolves with false: one unreadable transcript must not sink +// the whole snapshot, but the caller must know the scan is incomplete. function readLines(file, openFile, onLine) { return new Promise((resolve) => { let stream; try { stream = openFile(file, { encoding: "utf-8" }); } catch { - resolve(); + resolve(false); return; } let done = false; let rl = null; + // The flag is set by the error handlers only, before finish(): neither a + // 'close' after an error nor the destroy() below can turn it back. + let failed = false; const finish = () => { if (done) return; done = true; if (rl) rl.close(); if (typeof stream.destroy === "function") stream.destroy(); - resolve(); + resolve(!failed); + }; + const fail = () => { + failed = true; + finish(); }; // Listen on both: readline forwards the input's error to the interface, // and an unlistened 'error' there is an uncaught exception that kills the // process (and the interface then never emits 'close'). - stream.on("error", finish); + stream.on("error", fail); rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - rl.on("error", finish); + rl.on("error", fail); rl.on("line", onLine); rl.on("close", finish); }); @@ -294,6 +319,7 @@ function readLines(file, openFile, onLine) { * days: Array<{ date: string, models: Object }>, * projects: Array<{ name: string, work: number, cacheRead: number, requests: number }>, * claudeCodeVersion: string | null, + * readErrors: number, unreadable directories, stats and transcripts; 0 on a complete scan * }>} */ async function aggregate({ @@ -357,9 +383,11 @@ async function aggregate({ const day = torontoDay(ts); if (day < windowStart || day > today) return; - if (Object.prototype.hasOwnProperty.call(frozen, day)) return; + // Frozen days are NOT filtered here: see FROZEN DAYS ARE DROPPED AFTER + // DEDUPLICATION in the header. const entry = { + ts, day, model: typeof model === "string" && MODEL_KEY_RE.test(model) ? model : OTHER_MODEL, project: mapProject(obj.cwd, homedir), @@ -375,18 +403,27 @@ async function aggregate({ // it counts as its own request rather than being dropped. const key = id && reqId ? `${id}\u0000${reqId}` : `\u0001${anonymous++}`; const prev = requests.get(key); - if (!prev || entry.output > prev.output) requests.set(key, entry); + if (!prev || entry.output > prev.output || (entry.output === prev.output && entry.ts < prev.ts)) { + requests.set(key, entry); + } }; - for (const file of await listTranscripts(root)) { + let readErrors = 0; + const onError = () => { + readErrors += 1; + }; + for (const file of await listTranscripts(root, onError)) { let stat; try { stat = await fs.promises.stat(file); } catch { + // ENOENT included: a transcript removed between the walk and the stat + // may have held lines of a day about to be frozen. + onError(); continue; } if (torontoDay(stat.mtimeMs) < threshold) continue; - await readLines(file, openFile, onLine); + if (!(await readLines(file, openFile, onLine))) onError(); } // Open days: every non-frozen day of the window, empty ones included, so @@ -395,6 +432,8 @@ async function aggregate({ const openDays = dict(); for (const day of openDayList) openDays[day] = { models: dict(), projects: dict() }; for (const r of requests.values()) { + // A request whose best line sits on a frozen day was counted there. + if (Object.prototype.hasOwnProperty.call(frozen, r.day)) continue; const bucket = openDays[r.day]; if (!bucket.models[r.model]) bucket.models[r.model] = emptyModel(); addModel(bucket.models[r.model], { ...r, requests: 1 }); @@ -425,6 +464,7 @@ async function aggregate({ days, projects: topProjects(projectTotals), claudeCodeVersion: latest ? latest.version : null, + readErrors, }; } diff --git a/agent/claude-usage/limits.js b/agent/claude-usage/limits.js index 1ac23fc..a251970 100644 --- a/agent/claude-usage/limits.js +++ b/agent/claude-usage/limits.js @@ -29,7 +29,8 @@ 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. +// Far beyond the ~2 KB the endpoint answers. Counted in bytes while reading: +// past it the stream is cancelled, neither read further nor parsed (#54). const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_ITEMS = 12; const MAX_STRING = 128; @@ -175,9 +176,22 @@ function readCredentials(credentialsPath, readFile) { } async function readBody(res) { - const text = await res.text(); - if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large"); - return JSON.parse(text); + if (!res.body || typeof res.body.getReader !== "function") throw new Error("no body"); + const reader = res.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + // A timeout surfaces here as a TimeoutError/AbortError, mapped by the caller. + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + reader.cancel().catch(() => {}); + throw new Error("too large"); + } + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString("utf-8")); } function discardBody(res) { diff --git a/agent/claude-usage/push-claude-usage.js b/agent/claude-usage/push-claude-usage.js index 87756c0..412ffa6 100755 --- a/agent/claude-usage/push-claude-usage.js +++ b/agent/claude-usage/push-claude-usage.js @@ -177,7 +177,13 @@ async function main(argv = process.argv.slice(2), env = process.env, deps = {}) now, }); - const updated = nextState(state, { openDays: aggregated.openDays, today, limits }); + // 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) { diff --git a/agent/claude-usage/state.js b/agent/claude-usage/state.js index 7a83ddb..275eaa0 100644 --- a/agent/claude-usage/state.js +++ b/agent/claude-usage/state.js @@ -92,8 +92,9 @@ function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) * @param {object} opts.openDays aggregate().openDays * @param {string} opts.today Toronto "YYYY-MM-DD" * @param {object} opts.limits fetchLimits() result + * @param {boolean} [opts.freeze] false: freeze no new day (incomplete scan) */ -function nextState(state, { openDays = {}, today, limits = null }) { +function nextState(state, { openDays = {}, today, limits = null, freeze = true }) { const yesterday = shiftDay(today, -1); const oldest = shiftDay(today, -RETENTION_DAYS); @@ -101,7 +102,10 @@ function nextState(state, { openDays = {}, today, limits = null }) { for (const [day, value] of Object.entries(state.frozenDays || {})) { if (day >= oldest) frozenDays[day] = value; } - for (const [day, value] of Object.entries(openDays || {})) { + // freeze: false after a scan that hit a read error (#54): a day frozen + // with lines missing would stay wrong for RETENTION_DAYS. The next clean + // beat freezes it instead. + for (const [day, value] of Object.entries(freeze ? openDays || {} : {})) { if (!isDay(day) || day >= yesterday || day < oldest) continue; frozenDays[day] = { models: value.models || {}, projects: value.projects || {} }; } diff --git a/claude-usage.js b/claude-usage.js index 7097c7c..bdeb207 100644 --- a/claude-usage.js +++ b/claude-usage.js @@ -68,6 +68,13 @@ function cleanTimestamp(value) { return value; } +// Also used by index.js on the stored receivedAt of both snapshot files: the +// server only ever stamps Date#toISOString(), and new Date() alone would +// accept "1" or a bare date (#54). +function isIsoTimestamp(value) { + return cleanTimestamp(value) !== undefined; +} + // The calendar check catches what the regex lets through (2026-02-31). function cleanDayDate(value) { if (typeof value !== "string" || !DAY_DATE_RE.test(value)) return undefined; @@ -176,6 +183,7 @@ function sanitizeClaudeUsage(payload) { module.exports = { sanitizeClaudeUsage, + isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES, SCHEMA_VERSION, MAX_DAYS, diff --git a/index.js b/index.js index 8fb7769..97c0715 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ const { const path = require("node:path"); const { createHash, timingSafeEqual } = require("node:crypto"); const { collectMetrics } = require("./metrics.js"); -const { sanitizeClaudeUsage, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js"); +const { sanitizeClaudeUsage, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js"); const PORT = parseInt(process.env.PORT || "3001", 10); const TOKEN = process.env.HEALTH_TOKEN; @@ -500,7 +500,7 @@ function readHostSnapshot(id) { } const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null; - if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) { + if (!isIsoTimestamp(receivedAt)) { console.error(`[hosts] snapshot for ${id} has no usable receivedAt`); return null; } @@ -554,7 +554,7 @@ function readClaudeUsage() { } const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null; - if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) { + if (!isIsoTimestamp(receivedAt)) { console.error("[claude-usage] snapshot has no usable receivedAt"); return null; }