wip(#19): privacy-safe model metrics in progress — preserved snapshot before nightly release

Local work in progress, not reviewed and not part of any PR. Committed only so the
nightly release can build from a clean main without discarding it.
This commit is contained in:
timmy committed 2026-11-01 12:02:43 +00:00
1 parent 9067ce5306
commit 3e2253a052
15 files changed
+768 -9

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ A working mobile-first bowel diary with an optional **photo-first AI assist**:
- [Product boundary and release authority decision](docs/PRODUCT-DECISIONS.md) - [Product boundary and release authority decision](docs/PRODUCT-DECISIONS.md)
- [Self-hosted stool-vision decision](research/SELF-HOSTED-STOOL-VISION.md) - [Self-hosted stool-vision decision](research/SELF-HOSTED-STOOL-VISION.md)
- [Production topology, secrets, backup, and rollback runbook](docs/PRODUCTION-RUNBOOK.md) - [Production topology, secrets, backup, and rollback runbook](docs/PRODUCTION-RUNBOOK.md)
- [Privacy-safe model health counters (operator view)](docs/MODEL-METRICS.md)
## Build a gated review release ## Build a gated review release
+1 -1
View File
@@ -313,7 +313,7 @@ function withdrawTrainingContribution(contributionId){
} }
function privacy(){ function privacy(){
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${portabilityNoticeBlock()}${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`); shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div><div class="privacy-item" data-health-disclosure><b>♥</b><div><h3>Health counters</h3><p>Timmy’s server keeps in-memory counters of how analysis requests went — allowed, refused, failed, and how long they took — so an outage can be seen without opening anyone’s journal. They never include your notes, photos, image fingerprints, or identity, and they reset when the server restarts.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${portabilityNoticeBlock()}${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData; document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
} }
function exportData(){const blob=new Blob([exportLedgerBackup({entries,contributions})],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`timmy-backup-v${LEDGER_EXPORT_VERSION}.json`;a.click();URL.revokeObjectURL(a.href);portabilityNotice={kind:'ok',version:LEDGER_EXPORT_VERSION,text:`Backup written as version ${LEDGER_EXPORT_VERSION}: ${entries.length} log${entries.length===1?'':'s'} and ${contributions.length} training receipt${contributions.length===1?'':'s'}.`};render();toast('Backup exported');} function exportData(){const blob=new Blob([exportLedgerBackup({entries,contributions})],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`timmy-backup-v${LEDGER_EXPORT_VERSION}.json`;a.click();URL.revokeObjectURL(a.href);portabilityNotice={kind:'ok',version:LEDGER_EXPORT_VERSION,text:`Backup written as version ${LEDGER_EXPORT_VERSION}: ${entries.length} log${entries.length===1?'':'s'} and ${contributions.length} training receipt${contributions.length===1?'':'s'}.`};render();toast('Backup exported');}
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

+99
View File
@@ -0,0 +1,99 @@
# Privacy-safe model health counters
Issue [#19](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/19).
This is the operator surface for "is the worker healthy?", and it is deliberately the smallest one
that answers the question. The journal stays the only place a person's bowel data lives.
## Endpoint
```
GET /api/metrics
Authorization: Bearer $TIMMY_METRICS_TOKEN
```
- `TIMMY_METRICS_TOKEN` unset → **404**. A default deployment has no metrics route at all; nothing is
exposed by forgetting to configure something.
- Token set, header missing or wrong → **403**, with a body that contains no counters.
- Token set and matched → **200** with the snapshot below.
The snapshot is read from process memory. It survives no restart, is written to no log, and is sent
to no third party.
## What is in the snapshot
```json
{
"schema": 1,
"generatedAt": "2026-11-01T10:00:00.000Z",
"app": { "uptimeMs": 0, "analyzeRequests": 0, "refusedBeforeModel": 0 },
"queue": { "inFlight": 0, "limit": 4, "atCapacity": false, "accepted": 0 },
"model": {
"outcomes": { "suggestion": 0, "abstained": 0, "failure": 0 },
"failureClasses": { "ingress_refused": 0, "rate_limited": 0, "disabled": 0,
"provider_unreachable": 0, "provider_rejected": 0,
"schema_invalid": 0, "timeout": 0, "unexpected": 0 },
"abstentionRate": null,
"readiness": { "phase": "checking", "ready": false, "coldLoadMs": null },
"latency": { "count": 0, "meanMs": null, "maxMs": 0,
"bucketsMs": { "1000": 0, "2000": 0, "5000": 0, "15000": 0, "60000": 0 } }
}
}
```
The three blocks are the three boundaries, and each counts only what it owns:
| Block | Owns | Does not own |
|---|---|---|
| `app` | Every `POST /api/analyze` the process saw, and the ones refused before any model work (bad bytes, per-device budget, analysis disabled). | Queue occupancy or model answers. |
| `queue` | Slots handed out and released, and the configured concurrency ceiling. | Whether the request succeeded. |
| `model` | Answers (`suggestion` / `abstained`), classified failures, readiness phase, and model-work latency. | Anything decided before a slot was taken. |
`abstentionRate` is `abstained / (suggestion + abstained)`; it is `null` until the model has answered
at least once. A failed request is **not** an abstention — an outage must not look like a cautious
model.
`latency.bucketsMs` is cumulative: `"5000"` counts every sample at or below 5 s. `coldLoadMs` is the
interval from process start to the first observation in which the pinned model was resident; a worker
that flaps back to `offline` keeps the original figure.
`readiness.phase` uses the same vocabulary the browser uses for the AI affordance (`checking`,
`disabled`, `offline`, `loading`, `ready`), and the value is produced by the same
`visionReadiness()` function the app renders, so a metric and a screen cannot disagree.
## What can never appear in it
The only strings this module stores are members of three frozen lists — outcomes, failure classes,
and readiness phases — plus a fixed latency-bucket edge set. Anything else is bucketed, not copied:
- an unrecognised outcome becomes `failure`;
- an unrecognised failure class becomes `unexpected`;
- an unrecognised readiness phase becomes `checking`.
Therefore no note text, no image bytes, no image hash or fingerprint, no subject key, no caller
address, and no session or cookie value can reach a label. `tests/model-metrics.test.js` asserts this
by feeding hostile strings into every label position, and
`tests/metrics-endpoint.acceptance.test.js` asserts it through the shipping server.
## Reading it
```bash
curl -sS -H "Authorization: Bearer $TIMMY_METRICS_TOKEN" http://127.0.0.1:4173/api/metrics | jq .
```
What the numbers mean to an operator:
| Signal | Reading |
|---|---|
| `readiness.phase: offline` | The worker is not answering. Manual logging is unaffected; do not restart the app for this. |
| `readiness.phase: loading` | The worker answered but the pinned model is not resident. AI is withheld rather than offered and failed. |
| `queue.atCapacity: true` | Every model slot is in use. Watch `latency.maxMs`; a growing figure with no completions is a stuck inference, not load. |
| `failureClasses.provider_unreachable` climbing | Network or worker outage, not caller error. |
| `failureClasses.schema_invalid` climbing | The worker is answering off-contract. Treat as a regression in the pinned model or prompt, not a user problem. |
| `app.refusedBeforeModel` climbing with no model traffic | Abuse or a broken client; the model never saw it. |
## Known gap
`queue.inFlight` is the app's own admission depth for `POST /api/analyze`. The full bounded queue
(concurrency limit enforcement, cancellation, overload fallback) is issue
[#17](https://forge.alexanderwhitestone.com/git/stackchain/timmy-talking-turd/issues/17); this
snapshot reports the slot accounting that exists today and does not claim the rest.
+2 -1
View File
@@ -57,13 +57,14 @@ enforces that distinction and the app withholds the AI control until it passes.
## Secrets ## Secrets
Four secrets exist, and every one of them is injected at runtime: Five secrets exist, and every one of them is injected at runtime:
| Secret | Source | Scope | | Secret | Source | Scope |
|---|---|---| |---|---|---|
| `TIMMY_DATASET_SALT` | mode-600 environment file on the host | Corpus subject keys and split assignment. Rotating it re-splits future ingests, so it is not rotated casually. | | `TIMMY_DATASET_SALT` | mode-600 environment file on the host | Corpus subject keys and split assignment. Rotating it re-splits future ingests, so it is not rotated casually. |
| Vision provider key or worker token | mode-600 environment file on the host | Only the app process reads it; the browser never receives it. | | Vision provider key or worker token | mode-600 environment file on the host | Only the app process reads it; the browser never receives it. |
| Agent access code | mode-600 environment file on the host | Unlocks `/api/agent/unlock`; the cookie it mints is `HttpOnly` and same-origin. | | Agent access code | mode-600 environment file on the host | Unlocks `/api/agent/unlock`; the cookie it mints is `HttpOnly` and same-origin. |
| `TIMMY_METRICS_TOKEN` | mode-600 environment file on the host | Optional. Bearer token for the operator-only `/api/metrics` snapshot. When it is unset the route returns 404, so an unconfigured deployment exposes no counters at all. |
| Gitea access token | local operator shell or secret store | Issue and PR automation only. It is never written into a release tree, an archive, or a log. | | Gitea access token | local operator shell or secret store | Issue and PR automation only. It is never written into a release tree, an archive, or a log. |
Rules: Rules:
+2 -2
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js", "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js tests/abstention-policy.test.js tests/release-security-gate.test.js tests/model-metrics.test.js tests/metrics-endpoint.acceptance.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js",
"test:ui": "node tests/ui.acceptance.mjs", "test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs",
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
@@ -19,7 +19,7 @@
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs", "test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs", "test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs",
"test:training-contribution": "node tests/training-contribution.acceptance.mjs", "test:training-contribution": "node tests/training-contribution.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js", "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check src/abstention-policy.js && node --check src/release-security-gate.js && node --check scripts/security_attack_suite.mjs && node --check tests/security-attack.acceptance.mjs && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/corpus_manifest.py scripts/review_corpus.py scripts/dataset_card.py scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py scripts/calibration_report.py scripts/operator_backup.py && node --check scripts/record_worker_benchmark.mjs && node --check scripts/abstention_policy_receipt.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check src/model-metrics.js && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js",
"check:diff": "bash scripts/check_diff.sh", "check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs", "start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs", "test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
+2
View File
@@ -71,6 +71,7 @@ DEMO_REQUIRED_CAPTIONS = (
"An older backup is upgraded on import, and the notice says which version the file came from", "An older backup is upgraded on import, and the notice says which version the file came from",
"A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched", "A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched",
"A restored log still says it began as an AI suggestion you corrected — provenance is part of the record", "A restored log still says it began as an AI suggestion you corrected — provenance is part of the record",
"Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity",
"The review tool is a command line, not a screen in the app — this runs it against the committed synthetic sample", "The review tool is a command line, not a screen in the app — this runs it against the committed synthetic sample",
"The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits", "The queue shows the proposal and hides the model answer; revealing it is refused until a reviewer commits",
"The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over", "The model said Type 6 and the reviewer said Type 5: the disagreement is recorded, not smoothed over",
@@ -397,6 +398,7 @@ def main() -> int:
"production_runbook_commands": "passed", "production_runbook_commands": "passed",
"portable_backup_restore": "passed", "portable_backup_restore": "passed",
"image_ingress_refusal": "passed", "image_ingress_refusal": "passed",
"privacy_safe_health_metrics": "passed",
"dependency_audit_high": "passed", "dependency_audit_high": "passed",
"syntax": "passed", "syntax": "passed",
"secret_scan": "passed", "secret_scan": "passed",
+12
View File
@@ -1525,6 +1525,18 @@ await tap('#entry-sheet-close', 450);
await tap('[data-view="privacy"]', 550); await tap('[data-view="privacy"]', 550);
await page.locator('#delete-all').waitFor(); await page.locator('#delete-all').waitFor();
// Issue #19 — the operator health counters are disclosed where a user reads privacy facts, and the
// recording shows the real sentence the app renders, checked in the browser first.
const healthDisclosure = page.locator('[data-health-disclosure]');
await healthDisclosure.waitFor();
const healthCopy = (await healthDisclosure.innerText()).trim();
if (!/how analysis requests went/i.test(healthCopy) || !/image fingerprints/i.test(healthCopy) || !/reset/i.test(healthCopy)) {
throw new Error(`the privacy screen must disclose the privacy-safe health counters: ${healthCopy}`);
}
await page.evaluate(node => node.scrollIntoView({ block: 'center' }), await healthDisclosure.elementHandle());
await caption('Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity', 3200);
await indicate('[data-health-disclosure]');
await indicate('#delete-all'); await indicate('#delete-all');
await tap('#delete-all', 650); await tap('#delete-all', 650);
await page.getByText(/Local ledger and photos deleted/).waitFor(); await page.getByText(/Local ledger and photos deleted/).waitFor();
+62 -5
View File
@@ -1,6 +1,6 @@
import http from 'node:http'; import http from 'node:http';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path'; import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -10,6 +10,8 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
import { readJsonBody } from './src/http-body.js'; import { readJsonBody } from './src/http-body.js';
import { inspectImageBytes, IngressError } from './src/image-ingress.js'; import { inspectImageBytes, IngressError } from './src/image-ingress.js';
import { createRateLimiter } from './src/rate-limit.js'; import { createRateLimiter } from './src/rate-limit.js';
import { createModelMetrics } from './src/model-metrics.js';
import { visionReadiness } from './src/vision-readiness.js';
const root=fileURLToPath(new URL('.',import.meta.url)); const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173); const port=Number(process.env.PORT||4173);
@@ -42,6 +44,36 @@ function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,chara
// that lives only in this process's memory, so no address and no image byte is ever logged. // that lives only in this process's memory, so no address and no image byte is ever logged.
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000); const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)}); const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
// Issue #19: operator health counters. The token is what makes this an operator surface rather than
// a public one — with no token configured the route does not exist at all.
const metricsToken=String(process.env.TIMMY_METRICS_TOKEN||'');
const modelMetrics=createModelMetrics({limit:Number(process.env.TIMMY_MODEL_CONCURRENCY||4)});
function classifyFailure(error){
const code=String(error?.code||'');
if(code==='invalid_json'||code==='invalid_fields')return 'schema_invalid';
const message=String(error?.message||'');
if(/timed out|timeout|abort/i.test(message))return 'timeout';
// A payload the semantic validator rejects never reached the model, so it is an ingress refusal
// rather than a model fault.
if(/consent|JPEG|PNG|WebP|empty|too large/i.test(message))return 'ingress_refused';
if(/invalid response/i.test(message))return 'provider_rejected';
return 'provider_unreachable';
}
// Both the browser's readiness probe and the operator snapshot record the same observation, so the
// cold-load figure reflects the moment the model actually became resident, not the first time an
// operator happened to look.
function observeReadiness(provider){
const readiness=visionReadiness({enabled:visionConfig.enabled,providerReady:provider.ready,modelSeen:provider.modelSeen});
modelMetrics.recordReadiness({phase:readiness.phase,ready:readiness.ready});
return readiness;
}
// A token comparison that does not leak its prefix through response timing. The digests are equal
// length by construction, so timingSafeEqual can be used even for a wrong-length guess.
function operatorTokenMatches(supplied){
if(!metricsToken||!supplied)return false;
const digest=value=>createHash('sha256').update(String(value)).digest();
return timingSafeEqual(digest(supplied),digest(metricsToken));
}
const bucketSalt=randomBytes(16).toString('hex'); const bucketSalt=randomBytes(16).toString('hex');
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);} function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});} function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
@@ -72,21 +104,34 @@ http.createServer(async(req,res)=>{
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled}); if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
if(appPath==='/api/vision-status'&&req.method==='GET'){ if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig); const provider=await probeVisionProvider(visionConfig);
observeReadiness(provider);
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.'; const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
} }
// Operator-only health snapshot (issue #19). With TIMMY_METRICS_TOKEN unset the route is absent,
// so a default deployment exposes no counters at all.
if(appPath==='/api/metrics'&&req.method==='GET'){
if(!metricsToken)return sendJson(res,404,{error:'Not found'});
if(!operatorTokenMatches(String(req.headers.authorization||'').replace(/^Bearer\s+/i,'')))return sendJson(res,403,{error:'Operator token required.'});
observeReadiness(await probeVisionProvider(visionConfig));
return sendJson(res,200,modelMetrics.snapshot());
}
if(appPath==='/api/analyze'&&req.method==='POST'){ if(appPath==='/api/analyze'&&req.method==='POST'){
// Refusals first, in cost order: the request budget, then the body, then the image bytes. None of // Refusals first, in cost order: the request budget, then the body, then the image bytes. None of
// these depend on the model, so hostile bytes are refused as hostile even when analysis is off — // these depend on the model, so hostile bytes are refused as hostile even when analysis is off —
// otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested. // otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested.
const startedAtMs=Date.now();
const recordFailure=failureClass=>modelMetrics.recordAnalyze({outcome:'failure',failureClass,durationMs:Date.now()-startedAtMs});
const budget=analyzeLimiter.take(clientBucket(req)); const budget=analyzeLimiter.take(clientBucket(req));
if(!budget.allowed){ if(!budget.allowed){
recordFailure('rate_limited');
res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000)))); res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'}); return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
} }
let payload; let payload;
try{payload=await readJson(req)} try{payload=await readJson(req)}
catch(error){ catch(error){
recordFailure('ingress_refused');
const sent=sendIngressError(res,error); const sent=sendIngressError(res,error);
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot // The refusal is written first, then the half-read socket is dropped so a stalled body cannot
// hold a connection or its partial buffer open. // hold a connection or its partial buffer open.
@@ -94,11 +139,23 @@ http.createServer(async(req,res)=>{
return sent; return sent;
} }
try{ingressGuard(payload)} try{ingressGuard(payload)}
catch(error){return sendIngressError(res,error)} catch(error){recordFailure('ingress_refused');return sendIngressError(res,error)}
// Bytes are known to be a real photo at this point, so the disabled switch is the honest answer. // Bytes are known to be a real photo at this point, so the disabled switch is the honest answer.
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); if(!visionConfig.enabled){recordFailure('disabled');return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'})}
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} // The slot is what the queue boundary reports, and it is released on every exit path so a failed
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)} // analysis cannot quietly consume concurrency.
const slot=modelMetrics.enter();
try{
const result=await analyzePhoto({payload,config:visionConfig});
modelMetrics.recordAnalyze({outcome:result?.status==='suggestion'?'suggestion':'abstained',durationMs:Date.now()-startedAtMs});
return sendJson(res,200,result);
}
catch(error){
const failure=visionFailureResponse(error);
recordFailure(classifyFailure(error));
return sendJson(res,failure.status,failure.body);
}
finally{modelMetrics.leave(slot)}
} }
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent'))); if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
if(appPath==='/api/agent/unlock'&&req.method==='POST'){ if(appPath==='/api/agent/unlock'&&req.method==='POST'){
+139
View File
@@ -0,0 +1,139 @@
// Privacy-safe model health metrics for issue #19.
//
// The point of this module is the boundary it does not cross: a metric sample is assembled from
// enumerated outcomes and numbers, never from a request. No note text, no photo hash, no image
// bytes, and no caller identity can reach a label, because the only strings this module stores are
// members of the frozen lists below. Anything else is coerced to a bucket instead of copied.
export const OUTCOMES = Object.freeze(['suggestion', 'abstained', 'failure']);
// Failure classes are the operator's vocabulary for "what went wrong". They are chosen here, not
// observed from an error, so an upstream message can never become a metric label.
export const FAILURE_CLASSES = Object.freeze([
'ingress_refused',
'rate_limited',
'disabled',
'provider_unreachable',
'provider_rejected',
'schema_invalid',
'timeout',
'unexpected',
]);
// Readiness is the same vocabulary the browser already uses for the AI affordance, so a metric and
// the screen can never disagree about why AI is withheld.
export const READINESS_PHASES = Object.freeze(['checking', 'disabled', 'offline', 'loading', 'ready']);
// Failure classes that are decided before a request can occupy a queue slot or wait on the model.
const REFUSED_BEFORE_MODEL = Object.freeze(['ingress_refused', 'rate_limited', 'disabled']);
export function createModelMetrics({ now = () => Date.now(), limit = 4 } = {}) {
const outcomes = { suggestion: 0, abstained: 0, failure: 0 };
const failureClasses = Object.fromEntries(FAILURE_CLASSES.map(name => [name, 0]));
// The queue boundary is tracked by opaque tickets, not by request identity: the only thing that
// survives a request here is the count of slots in use.
const inUse = new Set();
let accepted = 0;
const startedAt = now();
let readiness = { phase: 'checking', ready: false, coldLoadMs: null };
let analyzeRequests = 0;
let refusedBeforeModel = 0;
// Latency is a histogram, never a list of individual timings: a bucket count says how the worker
// behaves without carrying a per-request record of when someone photographed their stool.
const bucketEdgesMs = Object.freeze([1_000, 2_000, 5_000, 15_000, 60_000]);
const bucketsMs = Object.fromEntries(bucketEdgesMs.map(edge => [String(edge), 0]));
let latencyCount = 0;
let latencySumMs = 0;
let latencyMaxMs = 0;
function abstentionRate() {
const answered = outcomes.suggestion + outcomes.abstained;
return answered === 0 ? null : outcomes.abstained / answered;
}
function recordLatency(durationMs) {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
latencyCount += 1;
latencySumMs += durationMs;
latencyMaxMs = Math.max(latencyMaxMs, durationMs);
// Edges are cumulative on purpose: `bucketsMs['5000']` answers "how many samples were at or
// below five seconds", so an operator can read a percentile without this module storing the
// individual timings that produced it.
for (const edge of bucketEdgesMs) {
if (durationMs <= edge) bucketsMs[String(edge)] += 1;
}
// Anything slower than the last edge stays counted in the total but out of the histogram, so a
// hanging request cannot invent a bucket.
}
return {
enter() {
const ticket = Symbol('model-slot');
inUse.add(ticket);
accepted += 1;
return ticket;
},
leave(ticket) {
// Deleting a ticket that is already gone is a no-op, so a double release cannot free a slot
// that a different request still holds.
inUse.delete(ticket);
},
recordReadiness({ phase, ready }) {
const next = READINESS_PHASES.includes(phase) ? phase : 'checking';
// A worker can flap between ready and offline. Only the first time the model became resident
// is a cold load; every later observation is just the current phase.
const coldLoadMs = readiness.coldLoadMs === null && next === 'ready' && ready === true
? Math.max(0, now() - startedAt)
: readiness.coldLoadMs;
readiness = { phase: next, ready: ready === true, coldLoadMs };
},
recordAnalyze({ outcome, failureClass, durationMs }) {
// The label is looked up, never stored: an unrecognised outcome becomes `failure` so the
// counters stay a closed set no matter what a caller sends.
const bucket = OUTCOMES.includes(outcome) ? outcome : 'failure';
outcomes[bucket] += 1;
analyzeRequests += 1;
if (bucket === 'failure') {
failureClasses[FAILURE_CLASSES.includes(failureClass) ? failureClass : 'unexpected'] += 1;
// A refusal decided before any model work (bad bytes, the per-device budget, or the disabled
// switch) is an app fact: it never occupied a queue slot and never waited on the model.
if (REFUSED_BEFORE_MODEL.includes(failureClass)) {
refusedBeforeModel += 1;
} else {
recordLatency(durationMs);
}
} else {
recordLatency(durationMs);
}
},
snapshot() {
return {
schema: 1,
generatedAt: new Date(now()).toISOString(),
app: {
uptimeMs: Math.max(0, now() - startedAt),
analyzeRequests,
refusedBeforeModel,
},
queue: {
inFlight: inUse.size,
limit,
atCapacity: inUse.size >= limit,
accepted,
},
model: {
outcomes: { ...outcomes },
failureClasses: { ...failureClasses },
abstentionRate: abstentionRate(),
readiness: { ...readiness },
latency: {
count: latencyCount,
meanMs: latencyCount === 0 ? null : Math.round(latencySumMs / latencyCount),
maxMs: latencyMaxMs,
bucketsMs: { ...bucketsMs },
},
},
};
},
};
}
+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath } from 'node:url';
// Issue #19 at the real boundary: the server that ships, behind a provider stub this test owns, so
// "the metric reflects a real request" and "the metric never contains the request" are both
// measurable instead of asserted from a mock.
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const OPERATOR_TOKEN = 'test-operator-token-9f2a';
function cleanJpeg({ width = 1200, height = 900 } = {}) {
const sof = Buffer.alloc(17);
sof.writeUInt16BE(0xffc0, 0);
sof.writeUInt16BE(15, 2);
sof[4] = 8;
sof.writeUInt16BE(height, 5);
sof.writeUInt16BE(width, 7);
sof[9] = 3;
for (let i = 0; i < 3; i += 1) {
sof[10 + i * 3] = i + 1;
sof[11 + i * 3] = 0x11;
}
return Buffer.concat([Buffer.from([0xff, 0xd8]), sof, Buffer.from([0xff, 0xd9])]);
}
const dataUrl = (mime, bytes) => `data:${mime};base64,${bytes.toString('base64')}`;
async function freePort() {
const server = net.createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
server.close();
return port;
}
async function startProvider() {
const state = { calls: 0 };
const server = http.createServer(async (req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'metrics-test-model' }] }));
return;
}
state.calls += 1;
for await (const _ of req) { /* drain */ }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{
message: {
content: JSON.stringify({
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
imageQuality: 'good', observations: 'Synthetic test card.',
}),
},
}],
}));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return {
state,
port: server.address().port,
close: () => new Promise(resolve => {
server.closeAllConnections?.();
server.close(resolve);
}),
};
}
async function withServer(env, run) {
const provider = await startProvider();
const port = await freePort();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: repoRoot,
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
TIMMY_VISION_ENABLED: 'true',
TIMMY_VISION_PROFILE: 'selfhost',
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
TIMMY_VISION_MODEL: 'metrics-test-model',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', chunk => logs.push(chunk.toString()));
child.stderr.on('data', chunk => logs.push(chunk.toString()));
const base = `http://127.0.0.1:${port}`;
try {
const deadline = Date.now() + 15_000;
for (;;) {
try {
const response = await fetch(`${base}/api/healthz`);
if (response.ok) break;
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
await run({ base, provider, logs });
} finally {
child.kill('SIGKILL');
await provider.close();
}
}
const postAnalyze = (base, payload) => fetch(`${base}/api/analyze`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const getMetrics = (base, token = OPERATOR_TOKEN) => fetch(`${base}/api/metrics`, {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
test('the metrics snapshot reports the request the server actually analysed', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base, provider }) => {
const analyzed = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) });
assert.equal(analyzed.status, 200);
assert.equal(provider.state.calls, 1);
const response = await getMetrics(base);
const snapshot = await response.json();
assert.equal(response.status, 200);
assert.equal(snapshot.app.analyzeRequests, 1);
assert.equal(snapshot.queue.accepted, 1);
assert.equal(snapshot.queue.inFlight, 0, 'a finished request must release its slot');
assert.equal(snapshot.model.outcomes.suggestion, 1);
assert.equal(snapshot.model.latency.count, 1);
assert.equal(snapshot.model.abstentionRate, 0);
assert.equal(snapshot.model.readiness.ready, true, 'readiness is probed from the configured worker');
});
});
test('the metrics route is operator-only, and absent when no token is configured', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base }) => {
const wrong = await getMetrics(base, 'not-the-operator-token');
assert.equal(wrong.status, 403);
assert.doesNotMatch(await wrong.text(), /analyzeRequests/, 'a rejected caller learns nothing about traffic');
const anonymous = await getMetrics(base, '');
assert.equal(anonymous.status, 403);
});
await withServer({}, async ({ base }) => {
const response = await getMetrics(base, '');
assert.equal(response.status, 404, 'an unconfigured deployment exposes no metrics route at all');
});
});
test('the snapshot proves the privacy boundary from the outside', async () => {
await withServer({ TIMMY_METRICS_TOKEN: OPERATOR_TOKEN }, async ({ base }) => {
// A real synthetic photo goes through the real path, so any leak would have real bytes to leak.
const bytes = cleanJpeg();
const analyzed = await postAnalyze(base, {
consent: true,
note: 'fictional note about a very private symptom',
imageDataUrl: dataUrl('image/jpeg', bytes),
});
assert.equal(analyzed.status, 200);
const snapshot = await getMetrics(base).then(response => response.json());
const serialized = JSON.stringify(snapshot);
assert.doesNotMatch(serialized, /very private symptom/i, 'note text must never be a metric');
const base64Run = bytes.toString('base64').slice(0, 24);
assert.ok(base64Run.length === 24, 'sanity: the synthetic image has bytes to leak');
assert.doesNotMatch(serialized, new RegExp(base64Run.replace(/[+/=]/g, '\\$&')), 'image bytes must never be a metric');
assert.doesNotMatch(serialized, /data:image/, 'no data URL may appear in the snapshot');
assert.doesNotMatch(serialized, /127\.0\.0\.1|::1/, 'no caller address may appear in the snapshot');
assert.doesNotMatch(serialized, /image|note|photo/i, 'no request-derived key may appear in the snapshot');
});
});
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn, execFile } from 'node:child_process';
import { once } from 'node:events';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const execFileAsync = promisify(execFile);
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const OPERATOR_TOKEN = 'report-operator-token-4b1c';
async function freePort() {
const server = net.createServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const { port } = server.address();
server.close();
return port;
}
async function startProvider() {
const server = http.createServer(async (req, res) => {
if (req.url === '/v1/models') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ data: [{ id: 'report-model' }] }));
return;
}
for await (const _ of req) { /* drain */ }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
choices: [{ message: { content: JSON.stringify({
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
imageQuality: 'good', observations: 'Synthetic test card.',
}) } }],
}));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return { port: server.address().port, close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(resolve); }) };
}
test('the operator report renders the app, queue, and model boundaries from a live snapshot', async () => {
const provider = await startProvider();
const port = await freePort();
const child = spawn(process.execPath, ['server.mjs'], {
cwd: repoRoot,
env: {
...process.env,
HOST: '127.0.0.1',
PORT: String(port),
TIMMY_VISION_ENABLED: 'true',
TIMMY_VISION_PROFILE: 'selfhost',
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
TIMMY_VISION_MODEL: 'report-model',
TIMMY_METRICS_TOKEN: OPERATOR_TOKEN,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
const logs = [];
child.stdout.on('data', c => logs.push(c.toString()));
child.stderr.on('data', c => logs.push(c.toString()));
const base = `http://127.0.0.1:${port}`;
try {
const deadline = Date.now() + 15_000;
for (;;) {
try {
const response = await fetch(`${base}/api/healthz`);
if (response.ok) break;
} catch { /* not up yet */ }
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
const { stdout } = await execFileAsync(process.execPath, ['scripts/model_metrics_report.mjs', '--url', base, '--token', OPERATOR_TOKEN], { cwd: repoRoot });
assert.match(stdout, /APP/);
assert.match(stdout, /QUEUE/);
assert.match(stdout, /MODEL/);
assert.match(stdout, /readiness\s+ready/, 'the report must name the readiness phase it observed');
assert.match(stdout, /abstention/i);
assert.match(stdout, /failure/i);
assert.doesNotMatch(stdout, /OPERATOR_TOKEN|report-operator-token/, 'the report must never print the token');
} finally {
child.kill('SIGKILL');
await provider.close();
}
});
+151
View File
@@ -0,0 +1,151 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createModelMetrics } from '../src/model-metrics.js';
// Issue #19: the worker has to be operable without the metrics becoming a second copy of the
// journal. These tests are the boundary: what a metric sample may say, and what it may never say.
test('abstention rate is computed from the model answers that came back at all', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 1_100 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 700 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 800 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.outcomes.suggestion, 2);
assert.equal(snapshot.model.outcomes.abstained, 2);
assert.equal(snapshot.model.abstentionRate, 0.5);
});
test('a label that is not an enumerated outcome is counted, never copied into the snapshot', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
// The worst case for a metrics boundary: a caller-supplied string arrives where a label belongs.
metrics.recordAnalyze({ outcome: 'Type 4, urgent, 2026-11-01, milkY4B2cQ9w==', durationMs: 900 });
const snapshot = metrics.snapshot();
const serialized = JSON.stringify(snapshot);
assert.deepEqual(Object.keys(snapshot.model.outcomes).sort(), ['abstained', 'failure', 'suggestion']);
assert.doesNotMatch(serialized, /urgent/i);
assert.doesNotMatch(serialized, /milkY4B2cQ9w/);
assert.equal(snapshot.model.outcomes.failure, 1, 'an unrecognised outcome is one failed answer');
});
test('a failed request is classified and kept out of the abstention denominator', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'provider_unreachable', durationMs: 2_000 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'provider_unreachable', durationMs: 2_100 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 800 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.failureClasses.provider_unreachable, 2);
assert.equal(snapshot.model.abstentionRate, 0.5, 'a failed answer is not an abstention');
});
test('a failure class outside the enumerated set collapses instead of being echoed', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'note:blood in stool, image 9f2a', durationMs: 2_000 });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.failureClasses.unexpected, 1);
assert.doesNotMatch(JSON.stringify(snapshot), /blood in stool/);
});
test('analysis latency is reported as warm work, with counts and a bounded histogram', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 900 });
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 1_500 });
metrics.recordAnalyze({ outcome: 'abstained', durationMs: 6_000 });
// A refused request never waited on the model, so it must not be sampled as latency.
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'ingress_refused', durationMs: 5 });
const latency = metrics.snapshot().model.latency;
assert.equal(latency.count, 3, 'latency counts model work only');
assert.equal(latency.maxMs, 6_000);
assert.equal(latency.meanMs, 2_800);
// Fixed bucket edges keep the snapshot a histogram of numbers, not a list of timings.
assert.deepEqual(latency.bucketsMs, { '1000': 1, '2000': 2, '5000': 2, '15000': 3, '60000': 3 });
});
test('queue depth is the in-flight model work, and a released slot is never counted twice', async () => {
const metrics = createModelMetrics({ now: () => 1_000, limit: 2 });
const first = metrics.enter();
const second = metrics.enter();
assert.equal(metrics.snapshot().queue.inFlight, 2);
assert.equal(metrics.snapshot().queue.atCapacity, true);
metrics.leave(first);
metrics.leave(first); // a double release must not free a slot that is still in use
const queue = metrics.snapshot().queue;
assert.equal(queue.inFlight, 1, 'the second request still holds its slot');
assert.equal(queue.atCapacity, false);
assert.equal(queue.accepted, 2);
metrics.leave(second);
assert.equal(metrics.snapshot().queue.inFlight, 0);
assert.equal(metrics.snapshot().queue.accepted, 2, 'a completed request is still an accepted request');
});
test('readiness is reported as a phase, and the cold model load is measured once', async () => {
let clock = 10_000;
const metrics = createModelMetrics({ now: () => clock });
metrics.recordReadiness({ phase: 'loading', ready: false });
clock = 74_000; // 64s of weights loading
metrics.recordReadiness({ phase: 'ready', ready: true });
clock = 90_000;
metrics.recordReadiness({ phase: 'offline', ready: false });
const readiness = metrics.snapshot().model.readiness;
assert.equal(readiness.phase, 'offline');
assert.equal(readiness.ready, false);
assert.equal(readiness.coldLoadMs, 64_000, 'the cold load is the first time the model became resident');
});
test('a readiness phase outside the enumerated set is reported as checking', async () => {
const metrics = createModelMetrics({ now: () => 1_000 });
metrics.recordReadiness({ phase: 'user says: bloody stool', ready: true });
const snapshot = metrics.snapshot();
assert.equal(snapshot.model.readiness.phase, 'checking');
assert.doesNotMatch(JSON.stringify(snapshot), /bloody stool/);
});
test('the snapshot keeps the app, queue, and model boundaries separate', async () => {
let clock = 1_000;
const metrics = createModelMetrics({ now: () => clock, limit: 2 });
// One request is refused at the app boundary and never takes a model slot; the other is admitted,
// answered, and released. Each of the three boundaries should account for exactly that.
const slot = metrics.enter();
metrics.recordAnalyze({ outcome: 'suggestion', durationMs: 800 });
metrics.leave(slot);
metrics.recordAnalyze({ outcome: 'failure', failureClass: 'rate_limited', durationMs: 0 });
clock = 41_000;
const snapshot = metrics.snapshot();
assert.deepEqual(Object.keys(snapshot).sort(), ['app', 'generatedAt', 'model', 'queue', 'schema']);
assert.equal(snapshot.app.uptimeMs, 40_000);
assert.equal(snapshot.app.analyzeRequests, 2, 'the app boundary counts every request it saw');
assert.equal(snapshot.app.refusedBeforeModel, 1, 'a refusal that never reached the queue is an app fact');
assert.equal(snapshot.queue.accepted, 1, 'the queue boundary counts what it admitted, not what it answered');
assert.equal(snapshot.queue.inFlight, 0, 'a released slot is not in flight');
assert.equal(snapshot.model.outcomes.suggestion, 1, 'the model boundary counts answers only');
});
+14
View File
@@ -561,3 +561,17 @@ test('release demo runs the RC attack suite on camera and prints the verdict it
assert.match(demo, /'providerTextLeaked' in observed/); assert.match(demo, /'providerTextLeaked' in observed/);
assert.doesNotMatch(demo, /http \$\{observed\.status\} · worker \$\{observed\.providerCalls\}`/); assert.doesNotMatch(demo, /http \$\{observed\.status\} · worker \$\{observed\.providerCalls\}`/);
}); });
test('release demo shows the privacy-safe health counters where the app discloses them', async () => {
const demo = await readFile(demoPath, 'utf8');
const builder = await readFile(builderPath, 'utf8');
// Issue #19 added an operator surface. The recording has to show the user-facing half of it — the
// disclosure on the privacy screen — and the claim has to be checked in the browser first.
assert.match(demo, /\[data-health-disclosure\]/);
assert.match(demo, /the privacy screen must disclose the privacy-safe health counters/);
assert.match(demo, /Health counters are disclosed where you read privacy facts — they never include notes, photos, image fingerprints, or identity/);
// The caption is a release gate, so a later release cannot drop the scene silently.
assert.match(builder, /Health counters are disclosed where you read privacy facts/);
assert.match(builder, /"privacy_safe_health_metrics": "passed"/);
});
+13
View File
@@ -43,6 +43,19 @@ await page.waitForTimeout(500);
assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame'); assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame');
await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false }); await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });
// Issue #19: the operator counters have to be disclosed where the user already looks for privacy
// facts, and the disclosure has to name what is not collected. A settings screen that stays silent
// about a new counter is the failure this asserts against.
await page.locator('[data-view="calendar"]').first().click();
await page.locator('[data-view="privacy"]').first().click();
const disclosure = page.locator('[data-health-disclosure]');
assert.equal(await disclosure.isVisible(), true, 'the privacy screen must disclose the health counters');
const disclosureText = await disclosure.innerText();
assert.match(disclosureText, /how analysis requests went/i);
assert.match(disclosureText, /never (contain|hold|include) your notes, photos, image fingerprints, or identity/i);
assert.match(disclosureText, /reset/i);
await page.screenshot({ path: 'artifacts/privacy-health-disclosure.png', fullPage: true });
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
await browser.close(); await browser.close();
console.log('PASS mobile home → log → red-flag → save → Timmy boundary'); console.log('PASS mobile home → log → red-flag → save → Timmy boundary');