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:
1 parent
9067ce5306
commit
3e2253a052
15 files changed
+768
-9
No files matched your search
@@ -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)
|
||||
- [Self-hosted stool-vision decision](research/SELF-HOSTED-STOOL-VISION.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
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ function withdrawTrainingContribution(contributionId){
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 |
@@ -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.
|
||||
@@ -57,13 +57,14 @@ enforces that distinction and the app withholds the AI control until it passes.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
| 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. |
|
||||
|
||||
Rules:
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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:photo": "node tests/photo-first.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:photo-lifecycle": "node tests/photo-lifecycle.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",
|
||||
"start": "node server.mjs",
|
||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
|
||||
|
||||
@@ -71,6 +71,7 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"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 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 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",
|
||||
@@ -397,6 +398,7 @@ def main() -> int:
|
||||
"production_runbook_commands": "passed",
|
||||
"portable_backup_restore": "passed",
|
||||
"image_ingress_refusal": "passed",
|
||||
"privacy_safe_health_metrics": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -1525,6 +1525,18 @@ await tap('#entry-sheet-close', 450);
|
||||
await tap('[data-view="privacy"]', 550);
|
||||
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 tap('#delete-all', 650);
|
||||
await page.getByText(/Local ledger and photos deleted/).waitFor();
|
||||
|
||||
+62
-5
@@ -1,6 +1,6 @@
|
||||
import http from 'node:http';
|
||||
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 { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -10,6 +10,8 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
|
||||
import { readJsonBody } from './src/http-body.js';
|
||||
import { inspectImageBytes, IngressError } from './src/image-ingress.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 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.
|
||||
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)});
|
||||
// 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');
|
||||
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});}
|
||||
@@ -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/vision-status'&&req.method==='GET'){
|
||||
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.';
|
||||
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'){
|
||||
// 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 —
|
||||
// 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));
|
||||
if(!budget.allowed){
|
||||
recordFailure('rate_limited');
|
||||
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.'});
|
||||
}
|
||||
let payload;
|
||||
try{payload=await readJson(req)}
|
||||
catch(error){
|
||||
recordFailure('ingress_refused');
|
||||
const sent=sendIngressError(res,error);
|
||||
// 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.
|
||||
@@ -94,11 +139,23 @@ http.createServer(async(req,res)=>{
|
||||
return sent;
|
||||
}
|
||||
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.
|
||||
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
||||
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
|
||||
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)}
|
||||
if(!visionConfig.enabled){recordFailure('disabled');return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'})}
|
||||
// The slot is what the queue boundary reports, and it is released on every exit path so a failed
|
||||
// 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/unlock'&&req.method==='POST'){
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.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"/);
|
||||
});
|
||||
@@ -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');
|
||||
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, []);
|
||||
await browser.close();
|
||||
console.log('PASS mobile home → log → red-flag → save → Timmy boundary');
|
||||
Reference in new issue
Block a user