diff --git a/CHANGELOG.md b/CHANGELOG.md index 53cb335cb..9048aa07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## [1.40.0] — 2026-10-04 + +Back up the encryption key and refuse a key that cannot open the data; +several courses and custom categories for medications; custom symptoms; +document reads in the background; sick notes; lab unit refusal and charts +that follow the source priority; blood pressure no longer loses its +diastolic value; document previews work again. + +### Added + +- **Encryption key backup step (migration 0365).** Admin step with key id, + fingerprint and per-platform guidance (`HEALTHLOG_PLATFORM`), an optional + "check my copy" that compares and discards the pasted key, a reminder that + returns when the key changes. +- **Boot key check.** A canary per key id, probed oldest values first across + string and Bytes columns; a proven mismatch answers 503 + `encryption.key_mismatch` everywhere except `/api/health` and + `/api/version`, MCP included, and starts no worker; an unproven result + serves, warns and re-checks. `ENCRYPTION_KEY_CHECK=warn` as a last resort. +- **Medication courses and custom categories (#1024, #1041, migration + 0368).** Several courses per medication; compliance, streak, cadence and + dose history count every course and never the gaps; existing dated + medications become one course each. Own categories as `custom:` with + `categoryLabel`; the runtime category table is now in the schema. +- **Custom symptoms (#981, migration 0369).** Up to eight definitions, events + with a 0 to 10 intensity, a capture kind, one correlation channel per + symptom, a Coach block; gated on the Illness module. +- **Document AI as background runs (#1090, migrations 0366 and 0370).** + Index, summary, suggestions and extraction accept `Prefer: respond-async`; + the lab scan always runs in the background; `GET /api/ai-runs/{id}`. +- **`SICK_NOTE` document kind.** +- **Natural durations (#1101)** in the sleep field. +- **Reasoning effort per provider (#1126).** The Local and OpenAI-compatible + forms gain a Reasoning select (Default, Off, Low, Medium, High), stored on + the provider's chain entry; Default sends nothing, Off sends + `reasoning_effort: "none"`. A rejection of that field no longer switches + the endpoint's JSON mode off. + +### Fixed + +- **Blood pressure saved without its diastolic value (iOS #15, migration + 0371).** The iPhone app sends the two halves of a manual reading as two + requests under one `Idempotency-Key`, and the server answered the second + with the first one's cached response. The replay cache now stores a SHA-256 + fingerprint of method, path and body (JSON canonicalised, a multipart + boundary normalised) and replays only the same request; a different body + under the same key is saved. Readings saved before this stay incomplete; + `docs/ops/blood-pressure-missing-diastolic.md` lists them. +- **Document previews and scanned PDFs (#1124).** The image hoisted an older + `@napi-rs/canvas` binary next to the pinned loader, so every thumbnail + failed; the build now hoists the pinned version and fails on a mismatch. + PDFs render with pdfjs font, cmap and wasm data and a registered fallback + font instead of absent system fonts, and an all-blank render reports + `raster-failed`. The thumbnail job logs why a preview is missing. +- **Portable backups.** Cycle free text, custom mood labels, visit notes + and vaccination notes are carried readable and re-encrypted on restore + instead of as the source server's ciphertext; custom mood tags restore + without mood entries; a round-trip guard fails on any ciphertext in a + portable file. A medication update and its course share one transaction. +- **Lab unit mismatch (#1094).** A reading in another unit than its marker is + refused (`labs.unit.mismatch`) on every write path; a marker's unit is + locked while it has readings (`biomarkers.unit.locked`). +- **Source priority in series (#1108).** Pulse, HRV and SpO2 series read the + ladder's source per day; unranked types keep every source. +- **Sign-in over plain http (#1097).** Refused with + `auth.session.insecure_transport` before a password, code or challenge is + used; the sign-in page says so first. +- **Chart window (#1102).** Ends at the end of today in the profile time zone. +- **Workouts.** The insight cap survives a delete (migration 0367); a changed + `manual:` resubmit updates the workout and its paragraph. +- **Display.** Clinician decimals in the owner's locale, stated dates east of + UTC+12, archive month grouping, lab list columns without a range, icon + picker touch targets and labels, symptom entry load errors. + +### Changed + +- Quick-entry forms load on demand; the four eager route baselines are + lowered to the new measurements. + ## [1.39.9] — 2026-10-03 Workouts can be logged by hand, the body fat tile opens its own page, and diff --git a/Dockerfile b/Dockerfile index 6b871db84..20fe839da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,9 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build # name; see the note there for why guessing was wrong. RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \ > /app/.pdfjs-version +# The same for @napi-rs/canvas, whose native binary must match its JS loader. +RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \ + > /app/.canvas-version # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner @@ -120,21 +123,41 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')" # @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError); # (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH # the canvas package and the musl binary package to /app/node_modules/@napi-rs -# via symlink into the .pnpm store, and also drop the prebuilt .node into the -# canvas dir so the loader's local-file fallback (`require('./skia..node')`) -# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple. +# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas +# dir: the loader tries that local file (`require('./skia..node')`) +# before the platform package, so it decides which binary runs. +# +# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The +# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in, +# and this step used to take whichever `find | head -1` returned first. It +# returned the old binary, which then sat next to the new loader: rendering +# still worked, but `loadImage` called an `Image.decode` the old binary does +# not have, and every preview thumbnail failed with "image.decode is not a +# function". Name the version, and fail the build when the binary that lands +# in the canvas dir is not that version's. +COPY --from=builder /app/.canvas-version /tmp/.canvas-version RUN set -e; \ - CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \ - MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \ - NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \ - if [ -n "$CANVAS_DIR" ]; then \ - mkdir -p /app/node_modules/@napi-rs; \ - ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \ - [ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \ - [ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \ - chown -R nextjs:nodejs /app/node_modules/@napi-rs; \ - echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \ - else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi + CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \ + rm -f /tmp/.canvas-version; \ + CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \ + MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \ + if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \ + echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \ + echo "Present instead:"; \ + find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \ + exit 1; \ + fi; \ + NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \ + [ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \ + mkdir -p /app/node_modules/@napi-rs; \ + ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \ + ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \ + cp "$NODE_BIN" "$CANVAS_DIR/"; \ + chown -R nextjs:nodejs /app/node_modules/@napi-rs; \ + BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \ + [ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \ + cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \ + echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)" # pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render # path breaks in the standalone image), so the server does a runtime bare diff --git a/README.md b/README.md index 806f6649d..a757a5c08 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,9 @@ Actively developed — new releases roughly weekly, issue reports and PRs welcom HealthLog started as something I built for myself. That it has turned into a project other people use, care about and help shape still surprises me, and I am grateful for every part of it. -Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama). A special thanks to [@BernhardBuckel](https://github.com/BernhardBuckel), who has been working through the lab screens one careful pull request at a time, with tests and a plan before every change. +Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama). -Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough. +Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email, and especially to [@lutzkind](https://github.com/lutzkind), [@HercoGC](https://github.com/HercoGC), [@mills1975](https://github.com/mills1975), [@sreeramachandramurthy](https://github.com/sreeramachandramurthy), [@CritLoren](https://github.com/CritLoren), [@mbreitkreuz](https://github.com/mbreitkreuz), [@k-nacion](https://github.com/k-nacion), [@NebuPookins](https://github.com/NebuPookins), [@Nazza01](https://github.com/Nazza01), [@Cnote43](https://github.com/Cnote43) and [@juliaanvaneijndt](https://github.com/juliaanvaneijndt), whose reports shaped HealthLog again and again. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough. And thank you to everyone who supported the project with a coffee or by buying the iPhone app. I don't name anyone here, but I read every message that comes with it. It pays for the servers and the Apple developer account, and it tells me that HealthLog is worth the evenings. diff --git a/docs/CONTRIBUTING-CLIENTS.md b/docs/CONTRIBUTING-CLIENTS.md index 2f5660022..e2f627e8c 100644 --- a/docs/CONTRIBUTING-CLIENTS.md +++ b/docs/CONTRIBUTING-CLIENTS.md @@ -19,7 +19,7 @@ Every JSON route answers the same shape: `{ data, error }`, with `meta` on the e - Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once. - Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly. - Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features. -- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice. +- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice. ## Data semantics worth knowing before you sync diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 25431a0bd..bb71a2e8e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.39.9 + version: 1.40.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -162,12 +162,14 @@ paths: pattern: ^[A-Za-z0-9_\-:.]{8,128}$ minLength: 8 maxLength: 128 - description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the - work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED - rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no - key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different - route is a different key. While a first request under this key is still in flight, a second is refused with - 409 rather than queued." + description: "Makes this write safe to retry. Send the same key with the same body again and the first response is + replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. The body is compared as + JSON with key order ignored; a different body under the same key is not a retry and runs as a new write, + while the first response stays stored for retries of the first request. A key that does not match the + pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a + request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the + same key on a different route is a different key. While a first request under this key is still in flight, a + second with the same body is refused with 409 rather than queued." requestBody: required: true content: @@ -25855,6 +25857,28 @@ components: minimum: 10 maximum: 600 - type: "null" + localReasoningEffort: + description: Reasoning setting of the Local provider entry, stored on that entry of the provider chain. `none` = Off, + `null` = Default (nothing sent). Omitted leaves it untouched. + anyOf: + - type: string + enum: + - none + - low + - medium + - high + - type: "null" + compatReasoningEffort: + description: Reasoning setting of the OpenAI-compatible gateway entry, stored on that entry of the provider chain. + `none` = Off, `null` = Default (nothing sent). Omitted leaves it untouched. + anyOf: + - type: string + enum: + - none + - low + - medium + - high + - type: "null" description: "Partial update of the calling user's AI-provider configuration. An omitted key is left untouched; `null` (or an empty string, on the string fields) clears the column. At least one recognised key must be present, or the write is refused. Key material is write-only: it is encrypted on the way in and never returned. Unknown keys @@ -27071,6 +27095,7 @@ components: properties: chain: minItems: 1 + maxItems: 7 type: array items: type: object @@ -27096,15 +27121,30 @@ components: maximum: 9007199254740991 enabled: type: boolean + reasoningEffort: + description: Only on a `local` or `openai-compatible` entry; refused on any other. Omitted keeps the value already + stored for that provider, `null` restores Default, `none` / `low` / `medium` / `high` are sent as + `reasoning_effort`. + anyOf: + - type: string + enum: + - none + - low + - medium + - high + - type: "null" required: - providerType - enabled + additionalProperties: false description: The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused. required: - chain + additionalProperties: false description: Replaces the account's provider chain wholesale. No credential is written or read here — only which - provider types exist, in what order, and which are enabled. + provider types exist, in what order, which are enabled, and the reasoning setting of the Local and + OpenAI-compatible entries. InsightsFeedbackRequest: type: object properties: @@ -36605,6 +36645,54 @@ components: anyOf: - type: string - type: "null" + note: + anyOf: + - type: string + - type: "null" + sensitive: + anyOf: + - anyOf: + - type: object + properties: + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + additionalProperties: false + - type: string + - type: "null" source: type: string enum: @@ -36708,6 +36796,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - key - labelKey @@ -36744,6 +36836,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" required: - id - key @@ -36801,6 +36897,10 @@ components: anyOf: - type: string - type: "null" + label: + anyOf: + - type: string + - type: "null" scaleMin: default: 1 type: integer @@ -38388,6 +38488,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -38455,6 +38559,18 @@ components: - type: string minLength: 1 - type: "null" + reason: + anyOf: + - type: string + - type: "null" + outcome: + anyOf: + - type: string + - type: "null" + bodySite: + anyOf: + - type: string + - type: "null" laterality: anyOf: - type: string @@ -38611,6 +38727,10 @@ components: - type: string minLength: 1 - type: "null" + note: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -48543,6 +48663,27 @@ components: - type: "null" description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends. + localReasoningEffort: + anyOf: + - type: string + enum: + - none + - low + - medium + - high + - type: "null" + description: "#1126 — reasoning setting of the Local provider entry, sent as `reasoning_effort` (`none` = Off). Null = + Default: the key is not sent." + compatReasoningEffort: + anyOf: + - type: string + enum: + - none + - low + - medium + - high + - type: "null" + description: "#1126 — the same for the OpenAI-compatible gateway entry." serverProviderHealth: type: string enum: @@ -48587,6 +48728,8 @@ components: - compatModel - hasCompatKey - responseTimeoutSeconds + - localReasoningEffort + - compatReasoningEffort - serverProviderHealth - serverProviderOffer - serverProviderConsent diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index c521a8c74..71d679ccc 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -171,8 +171,9 @@ tells you which rows to retry. ## Retries and duplicates Send an `Idempotency-Key` header with a value your automation can reproduce for -the same reading. A retry with the same key returns the original response -instead of writing a second row: +the same reading. A retry with the same key and the same body returns the +original response instead of writing a second row. A different body under the +same key is a different reading and is written: ```yaml headers: diff --git a/docs/ops/blood-pressure-missing-diastolic.md b/docs/ops/blood-pressure-missing-diastolic.md new file mode 100644 index 000000000..1509a6ade --- /dev/null +++ b/docs/ops/blood-pressure-missing-diastolic.md @@ -0,0 +1,60 @@ +# Blood pressure readings missing their diastolic half + +## What happened + +The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0) +saves a manually entered blood pressure as two requests to +`POST /api/measurements`: one `BLOOD_PRESSURE_SYS`, one +`BLOOD_PRESSURE_DIA`, both carrying the same `Idempotency-Key`. Until the +replay cache learned to compare request bodies, the server treated the second +request as a retry of the first. It answered with the first request's cached +`201` (header `X-Idempotent-Replay: true`) and never wrote the diastolic +value. The app showed success, so nobody noticed. + +The fix makes the cache replay only a request with the same body. A different +body under the same key now runs normally. Nothing recovers the values that +were never sent to the database: they exist only on the person's phone, if at +all. + +## Finding the affected readings + +Read-only. Run it against the HealthLog database (with the bundled compose +file: `docker compose exec db psql -U healthlog healthlog`). It lists every live systolic reading that has no +live diastolic reading for the same account at the same instant. + + + +```sql +SELECT u.username, + m.id, + m.measured_at, + m.value AS systolic, + m.source +FROM measurements m +JOIN users u ON u.id = m.user_id +WHERE m.type = 'BLOOD_PRESSURE_SYS' + AND m.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM measurements d + WHERE d.user_id = m.user_id + AND d.measured_at = m.measured_at + AND d.type = 'BLOOD_PRESSURE_DIA' + AND d.deleted_at IS NULL + ) +ORDER BY u.username, m.measured_at; +``` + + + +Readings from the phone app carry `source = 'MANUAL'`. Rows from a synced +source (Withings, Apple Health) that show up here are a different gap, not +this one: those sources write both halves themselves. + +## What to tell users + +There is nothing the server can repair: the diastolic value never reached +it. A person whose reading appears in the list can re-enter it: delete the +incomplete reading, then add the blood pressure again with both values and +the original date and time. From this release on, both halves are saved on +the first try, from any app version. diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index ea2c00bb2..adacbdd2c 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => { .locator('button[type="submit"]'), ).toBeEnabled(); }); + + // #1126 — the Reasoning select on the Local and OpenAI-compatible forms + // saves with the form, and at phone width it sits in the same column as + // the model field without pushing the page sideways. + test("reasoning setting saves from the Local and gateway forms at 390 px", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const patchBodies: Array> = []; + let providerState: Record = { + provider: "LOCAL", + model: null, + baseUrl: "http://localhost:11434/v1", + responseTimeoutSeconds: null, + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: "https://gateway.example.org/v1", + compatModel: "qwen3", + hasCompatKey: false, + localReasoningEffort: null, + compatReasoningEffort: null, + }; + await page.route("**/api/insights/settings", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + codexStatus: "disconnected", + codexConnectedAt: null, + hasAdminKey: false, + codexOauthConfigured: true, + privacyMode: "aggregated", + lastInsightAt: null, + }, + error: null, + }), + }), + ); + await page.route("**/api/user/ai-provider", async (route) => { + if (route.request().method() === "PATCH") { + const body = route.request().postDataJSON() as Record; + patchBodies.push(body); + providerState = { ...providerState, ...body }; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: providerState, error: null }), + }); + }); + await page.route("**/api/insights/provider-chain", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + activeProvider: "local", + cachedActiveProvider: null, + configuredChain: [ + { providerType: "local", enabled: true, available: true }, + ], + }, + error: null, + }), + }), + ); + + const providerHydrated = page.waitForResponse("**/api/user/ai-provider"); + await page.goto("/settings/ai?provider=local", { + waitUntil: "domcontentloaded", + }); + await providerHydrated; + + const localForm = page.getByTestId("ai-provider-config-local"); + const localReasoning = page.locator("#ai-local-reasoning"); + await expect(localReasoning).toHaveValue(""); + // Same column, same width as the model select above it. + const [modelBox, reasoningBox] = await Promise.all([ + page.locator("#ai-local-model").boundingBox(), + localReasoning.boundingBox(), + ]); + expect(reasoningBox?.x).toBe(modelBox?.x); + expect(reasoningBox?.width).toBe(modelBox?.width); + expect( + await page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ).toBe(true); + + await localReasoning.selectOption("none"); + await localForm.locator('button[type="submit"]').click(); + await expect.poll(() => patchBodies.length).toBe(1); + expect(patchBodies[0].localReasoningEffort).toBe("none"); + + await page + .getByTestId("ai-active-provider-select") + .selectOption("openai-compatible"); + const compatForm = page.getByTestId("ai-provider-config-openai-compatible"); + const compatReasoning = page.locator("#ai-compat-reasoning"); + await expect(compatReasoning).toHaveValue(""); + await compatReasoning.selectOption("high"); + await compatForm.locator('button[type="submit"]').click(); + await expect.poll(() => patchBodies.length).toBe(2); + expect(patchBodies[1].compatReasoningEffort).toBe("high"); + expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort"); + }); }); diff --git a/messages/de.json b/messages/de.json index 251f58dd9..bdc16bfd4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6081,6 +6081,17 @@ "title": "Coach-Pläne", "description": "Die Pläne, die du mit dem Coach vereinbart hast, zum Lesen oder Entfernen.", "open": "Pläne öffnen" + }, + "reasoning": { + "label": "Nachdenken", + "hint": "Reasoning-Modelle wie Gemma oder Qwen denken nach, bevor sie antworten. Schaltest du es aus, bleibt das ganze Antwortbudget für die Antwort selbst.", + "options": { + "default": "Standard", + "none": "Aus", + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", - "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)" + "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", + "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", + "restoreSkippedMoodLabelCiphertext": "Name eines Stimmungs-Tags oder einer Gruppe mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne Namen behalten)", + "restoreSkippedVisitCiphertext": "Text zu Arztbesuch oder Praxis mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (ohne ihn behalten)", + "restoreSkippedVaccinationCiphertext": "Impf-Notiz mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Impfung ohne sie behalten)" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 3196dad45..979ec1a04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6081,6 +6081,17 @@ "title": "Coach plans", "description": "The plans you agreed with the Coach, to read or remove.", "open": "Open plans" + }, + "reasoning": { + "label": "Reasoning", + "hint": "Reasoning models such as Gemma or Qwen think before they answer. Off leaves the whole answer budget for the answer itself.", + "options": { + "default": "Default", + "none": "Off", + "low": "Low", + "medium": "Medium", + "high": "High" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", - "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)" + "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", + "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", + "restoreSkippedMoodLabelCiphertext": "Mood tag or group name sealed with a key this server does not hold (kept without its name)", + "restoreSkippedVisitCiphertext": "Visit or practitioner text sealed with a key this server does not hold (kept without it)", + "restoreSkippedVaccinationCiphertext": "Vaccination note sealed with a key this server does not hold (dose kept without it)" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 2d6d6414c..50198919b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6081,6 +6081,17 @@ "title": "Planes del Coach", "description": "Los planes que acordaste con el Coach, para leerlos o eliminarlos.", "open": "Abrir planes" + }, + "reasoning": { + "label": "Razonamiento", + "hint": "Los modelos de razonamiento como Gemma o Qwen piensan antes de responder. Si lo desactivas, todo el presupuesto de respuesta se queda para la respuesta.", + "options": { + "default": "Predeterminado", + "none": "Desactivado", + "low": "Bajo", + "medium": "Medio", + "high": "Alto" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", - "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)" + "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", + "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", + "restoreSkippedMoodLabelCiphertext": "Nombre de etiqueta o grupo de ánimo cifrado con una clave que este servidor no tiene (se conserva sin el nombre)", + "restoreSkippedVisitCiphertext": "Texto de visita o profesional cifrado con una clave que este servidor no tiene (se conserva sin él)", + "restoreSkippedVaccinationCiphertext": "Nota de vacunación cifrada con una clave que este servidor no tiene (la dosis se conserva sin ella)" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 8e1bb35f1..19cd12f96 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6081,6 +6081,17 @@ "title": "Plans du Coach", "description": "Les plans convenus avec le Coach, à lire ou à supprimer.", "open": "Ouvrir les plans" + }, + "reasoning": { + "label": "Raisonnement", + "hint": "Les modèles de raisonnement comme Gemma ou Qwen réfléchissent avant de répondre. Désactivé, tout le budget de réponse sert à la réponse elle-même.", + "options": { + "default": "Par défaut", + "none": "Désactivé", + "low": "Faible", + "medium": "Moyen", + "high": "Élevé" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", - "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)" + "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", + "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", + "restoreSkippedMoodLabelCiphertext": "Nom d’étiquette ou de groupe d’humeur chiffré avec une clé que ce serveur ne possède pas (conservé sans son nom)", + "restoreSkippedVisitCiphertext": "Texte de consultation ou de praticien chiffré avec une clé que ce serveur ne possède pas (conservé sans)", + "restoreSkippedVaccinationCiphertext": "Note de vaccination chiffrée avec une clé que ce serveur ne possède pas (dose conservée sans)" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index bc6b6ab1a..9c77b4ab9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6081,6 +6081,17 @@ "title": "Piani del Coach", "description": "I piani concordati con il Coach, da leggere o rimuovere.", "open": "Apri i piani" + }, + "reasoning": { + "label": "Ragionamento", + "hint": "I modelli di ragionamento come Gemma o Qwen pensano prima di rispondere. Se lo disattivi, tutto il budget della risposta resta per la risposta stessa.", + "options": { + "default": "Predefinito", + "none": "Disattivato", + "low": "Basso", + "medium": "Medio", + "high": "Alto" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", - "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)" + "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", + "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", + "restoreSkippedMoodLabelCiphertext": "Nome di tag o gruppo dell’umore cifrato con una chiave che questo server non possiede (mantenuto senza nome)", + "restoreSkippedVisitCiphertext": "Testo di visita o professionista cifrato con una chiave che questo server non possiede (mantenuto senza)", + "restoreSkippedVaccinationCiphertext": "Nota di vaccinazione cifrata con una chiave che questo server non possiede (dose mantenuta senza)" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/ko.json b/messages/ko.json index b416ab808..2c0721ccb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6081,6 +6081,17 @@ "title": "코치 계획", "description": "코치와 합의한 계획을 읽거나 삭제할 수 있습니다.", "open": "계획 열기" + }, + "reasoning": { + "label": "추론", + "hint": "Gemma나 Qwen 같은 추론 모델은 답하기 전에 먼저 생각합니다. 끄면 답변 예산을 모두 답변 자체에 씁니다.", + "options": { + "default": "기본값", + "none": "끄기", + "low": "낮음", + "medium": "보통", + "high": "높음" + } } }, "dashboard": { @@ -7535,7 +7546,11 @@ "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", - "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)" + "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", + "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", + "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", + "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" }, "danger-zone": { "title": "위험 구역", diff --git a/messages/pl.json b/messages/pl.json index b907285fe..c8091d1c0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6081,6 +6081,17 @@ "title": "Plany z Coachem", "description": "Plany uzgodnione z Coachem, do przeczytania lub usunięcia.", "open": "Otwórz plany" + }, + "reasoning": { + "label": "Rozumowanie", + "hint": "Modele rozumujące, takie jak Gemma czy Qwen, myślą przed odpowiedzią. Po wyłączeniu cały budżet odpowiedzi idzie na samą odpowiedź.", + "options": { + "default": "Domyślnie", + "none": "Wyłączone", + "low": "Niskie", + "medium": "Średnie", + "high": "Wysokie" + } } }, "dashboard": { @@ -7556,7 +7567,11 @@ "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", - "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)" + "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", + "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", + "restoreSkippedMoodLabelCiphertext": "Nazwa tagu lub grupy nastroju zaszyfrowana kluczem, którego ten serwer nie ma (zachowana bez nazwy)", + "restoreSkippedVisitCiphertext": "Tekst wizyty lub lekarza zaszyfrowany kluczem, którego ten serwer nie ma (zachowany bez niego)", + "restoreSkippedVaccinationCiphertext": "Notatka szczepienia zaszyfrowana kluczem, którego ten serwer nie ma (dawka zachowana bez niej)" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/package.json b/package.json index 8f2a1b5f0..74f0243db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.39.9", + "version": "1.40.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0371_idempotency_request_fingerprint/migration.sql b/prisma/migrations/0371_idempotency_request_fingerprint/migration.sql new file mode 100644 index 000000000..dcb360852 --- /dev/null +++ b/prisma/migrations/0371_idempotency_request_fingerprint/migration.sql @@ -0,0 +1,8 @@ +-- What request a replay-cache cell answered, as a SHA-256 of the canonical +-- request body scoped by method and path. A request that reuses a key with a +-- different body is executed instead of being handed the first body's cached +-- response. The body itself is never stored. Nullable: rows written before +-- this release carry none, and a null never matches, so they stop replaying +-- for anything but their own retries already in flight (they expire within +-- 24 hours either way). +ALTER TABLE "idempotency_keys" ADD COLUMN IF NOT EXISTS "request_fingerprint" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a05a5659..9250aef3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5545,15 +5545,19 @@ model ProviderHealth { /// (userId, key, method, path) so retries within the TTL replay the /// original outcome instead of producing a duplicate side-effect. model IdempotencyKey { - id String @id @default(cuid()) - userId String @map("user_id") - key String - method String - path String - responseStatus Int @map("response_status") - responseBody String @map("response_body") // serialized JSON envelope - expiresAt DateTime @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + userId String @map("user_id") + key String + method String + path String + responseStatus Int @map("response_status") + responseBody String @map("response_body") // serialized JSON envelope + /// SHA-256 of the canonical request body, scoped by method and path. A + /// lookup replays only when it matches; a different body under the same key + /// runs normally. Null on rows written before it existed, which never match. + requestFingerprint String? @map("request_fingerprint") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/public/sw.js b/public/sw.js index 12e07572b..32351a83e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.39.9"; + /* @sw-version-fallback */ "v1.40.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/ai-reasoning-effort-guard.test.ts b/src/__tests__/ai-reasoning-effort-guard.test.ts new file mode 100644 index 000000000..e555ab141 --- /dev/null +++ b/src/__tests__/ai-reasoning-effort-guard.test.ts @@ -0,0 +1,124 @@ +/** + * Structural guard (#1126): `reasoning_effort` reaches a model only through + * the setting the resolver bound to the provider instance. + * + * Default must send nothing, so the key may not appear on any wire except as + * the bound value, and only the two clients whose endpoint a person chose may + * carry it. Pinned here: + * + * 1. the key appears in exactly the Local client and the OpenAI client, + * once each, and only as `reasoning_effort: this.reasoningEffort`; + * 2. nothing but the binder assigns `.reasoningEffort`; + * 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`. + * + * That clients are constructed only in `provider.ts` (so a resolver always + * binds them) is frozen by `ai-call-timeout-guard.test.ts`. + * + * Its limit: it reads source text. A body built under a computed key, or a + * resolver that binds through an alias, would slip it. + * `reasoning-effort.test.ts` and `provider-reasoning-effort.test.ts` prove + * the behaviour on the wire. + * + * Mutation check: hard-code `reasoning_effort: "none"` into either client, + * set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort` + * from `resolveProviderForTest`: each one goes red here. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function code(rel: string): string { + return stripComments(readFileSync(join(SRC, rel), "utf8")); +} + +const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); + +describe("reasoning_effort on the wire", () => { + // As an object key: the published descriptions name the field in prose. + const KEY = /\breasoning_effort["']?\s*:/g; + const sites = files.filter((rel) => new RegExp(KEY).test(code(rel))); + + it("appears in the Local and the OpenAI client only", () => { + expect(sites.sort()).toEqual([ + "lib/ai/local-client.ts", + "lib/ai/openai-client.ts", + ]); + }); + + it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])( + "%s sends it once, as the bound value", + (rel) => { + const source = code(rel); + expect(source.match(new RegExp(KEY))?.length).toBe(1); + expect(source).toMatch( + /\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/, + ); + }, + ); +}); + +/** Where `.reasoningEffort` may be assigned, and why. */ +const ASSIGNMENT_SITES: Record = { + "lib/ai/reasoning-effort.ts": + "The binder, the one place a provider instance gets its value.", + "lib/ai/provider-chain.ts": + "Writes the stored chain entry, not a provider instance.", +}; + +describe("only the binder assigns it to a provider", () => { + it("names every assignment site, with a reason", () => { + const sites = files.filter((rel) => + /\.\s*reasoningEffort\s*=(?!=)/.test(code(rel)), + ); + expect(sites.sort()).toEqual(Object.keys(ASSIGNMENT_SITES).sort()); + }); +}); + +describe("every exported resolver binds it", () => { + const source = code("lib/ai/provider.ts"); + const resolvers = source + .split(/\nexport\s+/) + .map((chunk) => { + const m = /^async\s+function\s+(\w+)\s*\(/.exec(chunk); + if (!m) return null; + const header = chunk.slice(0, chunk.indexOf("{\n")); + if ( + !/Promise<\s*(?:AIProvider|ProviderChainResolved\[\])\s*>/.test(header) + ) { + return null; + } + const end = chunk.indexOf("\n}\n"); + return { name: m[1], body: chunk.slice(header.length, end) }; + }) + .filter((r): r is { name: string; body: string } => r !== null); + + it("finds the resolvers (an empty set is a failure, not a pass)", () => { + expect(resolvers.map((r) => r.name).sort()).toEqual([ + "resolveProvider", + "resolveProviderChain", + "resolveProviderForTest", + ]); + }); + + it.each(resolvers.map((r) => r.name))( + "%s calls bindReasoningEffort", + (name) => { + const resolver = resolvers.find((r) => r.name === name); + expect(resolver?.body ?? "").toMatch(/\bbindReasoningEffort\s*\(/); + }, + ); +}); diff --git a/src/__tests__/canvas-binary-hoist-guard.test.ts b/src/__tests__/canvas-binary-hoist-guard.test.ts new file mode 100644 index 000000000..d230fab26 --- /dev/null +++ b/src/__tests__/canvas-binary-hoist-guard.test.ts @@ -0,0 +1,65 @@ +/** + * Structural guard: the runtime image puts the @napi-rs/canvas native binary + * of the version the app pins next to that version's loader. + * + * ## The failure this freezes (GitHub #1124) + * + * The pnpm store holds two canvas versions: the one in package.json and an + * older one that pdf-parse's own pdfjs-dist pulls in. The runner stage picked + * the musl binary with `find | head -1`, got the old one, and copied it into + * the new loader's directory, where the loader looks first. PDF rendering + * still worked, so nothing looked wrong; `loadImage` called an + * `Image.decode` the old binary lacks, and every preview thumbnail failed + * with "image.decode is not a function" on both architectures. + * + * ## What it proves and what it does not + * + * The builder records the resolved canvas version, the runner hoists by that + * name, and the build stops when the copied binary's package is a different + * version. Whether the binary loads is proved when the image runs; this file + * reads the Dockerfile only. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const DOCKERFILE = readFileSync(join(process.cwd(), "Dockerfile"), "utf8"); + +function stage(name: string): string { + const start = DOCKERFILE.search(new RegExp(`^FROM .* AS ${name}$`, "m")); + expect(start, `the Dockerfile has a ${name} stage`).toBeGreaterThan(0); + const next = DOCKERFILE.slice(start + 1).search(/^FROM /m); + return next < 0 + ? DOCKERFILE.slice(start) + : DOCKERFILE.slice(start, start + 1 + next); +} + +describe("canvas binary hoist", () => { + it("records the resolved canvas version in the builder", () => { + expect(stage("builder")).toMatch( + /require\('\/app\/node_modules\/@napi-rs\/canvas\/package\.json'\)\.version"[\s\\]*>\s*\/app\/\.canvas-version/, + ); + }); + + it("hoists the loader and the musl binary of that version only", () => { + const runner = stage("runner"); + expect(runner).toContain( + "COPY --from=builder /app/.canvas-version /tmp/.canvas-version", + ); + expect(runner).toContain( + 'CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/', + ); + expect(runner).toContain("canvas-linux-*-musl@${CANVAS_VERSION}/"); + // A version-blind pick is the defect: any `canvas…@*` glob decides by + // readdir order which of the store's copies wins. + expect(runner).not.toMatch(/@napi-rs\+canvas[^"'\s]*@\*/); + }); + + it("fails the build when the copied binary is another version", () => { + const runner = stage("runner"); + expect(runner).toMatch( + /\[ "\$BIN_VERSION" = "\$CANVAS_VERSION" \] \|\| \{[^}]*exit 1; \}/, + ); + }); +}); diff --git a/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts b/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts index adb31bbdc..dc93fd8e2 100644 --- a/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts +++ b/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts @@ -58,10 +58,13 @@ vi.mock("@/lib/profile/health-facts", () => ({ import { NextRequest } from "next/server"; import { POST } from "../route"; +import { requestFingerprint } from "@/lib/idempotency"; /** The pending sentinel `findCached` recognises as "another call is running". */ const PENDING_STATUS = 0; +const BODY = JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }); + function request(): NextRequest { return new NextRequest("http://localhost/api/anamnesis/facts", { method: "POST", @@ -69,7 +72,7 @@ function request(): NextRequest { "content-type": "application/json", "idempotency-key": "key-in-flight", }, - body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }), + body: BODY, }); } @@ -99,6 +102,12 @@ describe("idempotency 409 envelope, over the real route", () => { it("serves `error` as a string when a request is already in flight", async () => { mocks.findUnique.mockResolvedValue({ id: "idem-1", + // The same request in flight: a different body would run instead. + requestFingerprint: requestFingerprint( + "POST", + "/api/anamnesis/facts", + new TextEncoder().encode(BODY), + ), responseStatus: PENDING_STATUS, responseBody: "", expiresAt: new Date(Date.now() + 60_000), diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts index 510fa5fe7..3413fc0a2 100644 --- a/src/app/api/insights/provider-chain/__tests__/put.test.ts +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -53,6 +53,8 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn(), + // #1126 — the stored chain, read for the reasoning settings it carries. + findUnique: vi.fn(async () => null), }, }, })); @@ -172,4 +174,93 @@ describe("PUT /api/insights/provider-chain", () => { { providerType: "codex", priority: 2, enabled: true }, ]); }); + + // #1126 — the reasoning setting rides on the local / openai-compatible + // entries. The chain editor only reorders and toggles, so saving the order + // must not wipe a value it never sent. + describe("reasoning setting", () => { + function storedChain(chain: unknown) { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + aiProviderChain: chain, + } as never); + } + async function put(chain: unknown[]) { + vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never); + return (PUT as (req: Request) => Promise)( + jsonRequest({ chain }), + ); + } + function persisted() { + return vi.mocked(prisma.user.update).mock.calls[0][0].data + .aiProviderChain; + } + + it("persists a value sent on a local entry", async () => { + storedChain(null); + const res = await put([ + { providerType: "local", enabled: true, reasoningEffort: "none" }, + { providerType: "openai", enabled: true }, + ]); + expect(res.status).toBe(200); + expect(persisted()).toEqual([ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "none", + }, + { providerType: "openai", priority: 2, enabled: true }, + ]); + }); + + it("keeps the stored value when the body leaves it out", async () => { + storedChain([ + { providerType: "openai", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "high", + }, + ]); + await put([ + { providerType: "openai-compatible", enabled: true }, + { providerType: "openai", enabled: true }, + ]); + expect(persisted()).toEqual([ + { + providerType: "openai-compatible", + priority: 1, + enabled: true, + reasoningEffort: "high", + }, + { providerType: "openai", priority: 2, enabled: true }, + ]); + }); + + it("clears it on null", async () => { + storedChain([ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "low", + }, + ]); + await put([ + { providerType: "local", enabled: true, reasoningEffort: null }, + ]); + expect(persisted()).toEqual([ + { providerType: "local", priority: 1, enabled: true }, + ]); + }); + + it("refuses it on an entry that cannot carry it", async () => { + const res = await put([ + { providerType: "anthropic", enabled: true, reasoningEffort: "none" }, + ]); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 8ea535990..477cb8159 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -1,6 +1,4 @@ import type { NextRequest } from "next/server"; -import { z } from "zod/v4"; - import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; import { apiSuccess, @@ -15,10 +13,9 @@ import { getLastWorkingProvider, } from "@/lib/ai/provider-runner"; import { prisma } from "@/lib/db"; -import { - parseProviderChain, - PROVIDER_CHAIN_TYPES, -} from "@/lib/ai/provider-chain"; +import { parseProviderChain } from "@/lib/ai/provider-chain"; +import { isReasoningProviderType } from "@/lib/ai/reasoning-effort"; +import { providerChainPutSchema } from "@/lib/validations/ai-provider"; /** * v1.4.16 phase B5b — read-only chain summary for the Settings → AI @@ -85,29 +82,6 @@ export const GET = apiHandler(async () => { }); }); -const chainEntrySchema = z - .object({ - providerType: z.enum( - PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]], - ), - // Priority is recomputed server-side from insertion order so a stale - // client cannot persist a chain whose displayed order disagrees with - // its priority field. The number is accepted (and may be present) but - // ignored on the wire. - priority: z.number().int().optional(), - enabled: z.boolean(), - }) - .strict(); - -const chainBodySchema = z - .object({ - chain: z - .array(chainEntrySchema) - .min(1, "Chain must contain at least one provider") - .max(PROVIDER_CHAIN_TYPES.length, "Too many providers"), - }) - .strict(); - export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "insights.provider_chain.update" } }); @@ -117,7 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); if (error) return error; - const parsed = chainBodySchema.safeParse(body); + const parsed = providerChainPutSchema.safeParse(body); if (!parsed.success) { return apiValidationError( "Invalid provider chain payload", @@ -142,11 +116,35 @@ export const PUT = apiHandler(async (request: NextRequest) => { // Normalise priority to insertion order (1-based). UI hands us the // visual order it wants persisted; we make priority match so a later // GET reflects the same order without depending on the client's math. - const normalised = parsed.data.chain.map((entry, idx) => ({ - providerType: entry.providerType, - priority: idx + 1, - enabled: entry.enabled, - })); + // + // #1126 — a reasoning setting the body leaves out is carried over from the + // stored entry of the same type, so the chain editor (which only reorders + // and toggles) and an older client cannot wipe it by saving the order. + // `null` clears it; the schema has already refused it on any type other + // than `local` / `openai-compatible`. + const stored = await prisma.user.findUnique({ + where: { id: user.id }, + select: { aiProviderChain: true }, + }); + const storedEffort = new Map( + parseProviderChain(stored?.aiProviderChain ?? null).map((e) => [ + e.providerType, + e.reasoningEffort ?? null, + ]), + ); + const normalised = parsed.data.chain.map((entry, idx) => { + const reasoningEffort = isReasoningProviderType(entry.providerType) + ? entry.reasoningEffort === undefined + ? (storedEffort.get(entry.providerType) ?? null) + : entry.reasoningEffort + : null; + return { + providerType: entry.providerType, + priority: idx + 1, + enabled: entry.enabled, + ...(reasoningEffort ? { reasoningEffort } : {}), + }; + }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 5545404eb..67d9fe8f5 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -114,6 +114,11 @@ const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The PUT writes inside one interactive transaction; run its callback + // against the same mocked client. + vi.mocked(prisma.$transaction).mockImplementation((async ( + fn: (tx: typeof prisma) => unknown, + ) => fn(prisma)) as never); // v1.40 — the category and course helpers' defaults, which // `resetAllMocks` clears. vi.mocked(resolveCourseFields).mockResolvedValue(new Map()); @@ -203,9 +208,9 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { vi.mocked(auditLog).mockResolvedValue(undefined); const res = await PUT(putReq({ endsOn: "2026-12-31" }), ROUTE_CTX); expect(res.status).toBe(200); - // v1.40 (#1024) — the window goes through the course writer, which - // writes the row and the latest course in one transaction; the plain - // row update no longer carries it. + // v1.40 (#1024) — the window goes through the course writer, inside the + // same transaction as the row update; the plain row update no longer + // carries it. const call = lastUpdateCall(); expect(call.data).not.toHaveProperty("endsOn"); expect(setCurrentWindow).toHaveBeenCalledWith( @@ -213,6 +218,7 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { medicationId: "m1", endsOn: new Date("2026-12-31"), }), + prisma, ); }); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c30e57891..3587a4dda 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -667,7 +667,12 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - const tombstoneOpenSlots = async (): Promise => { + // The rollup days the tombstoned slots sat on, recomputed once the + // transaction below has committed the tombstones. + const staleRollupDays = new Set(); + const tombstoneOpenSlots = async ( + db: Prisma.TransactionClient, + ): Promise => { // A schedule replace invalidates the open slot anchors the projector / // reminder worker minted for the OLD times: a pending 08:00 row for a // medication that now doses at 20:00 would linger as a phantom slot, @@ -689,12 +694,12 @@ export const PUT = apiHandler( scheduledFor: { gte: todayStart }, } as const; // Capture the affected slots BEFORE the tombstone so the compliance - // rollups for those days can be recomputed immediately below — + // rollups for those days can be recomputed after the commit — // `updateMany` does not return the touched rows. Without the // recompute a tombstoned pending kept counting as `scheduled` in // the rollup row until the next event-driven write for that day, // depressing the rate the cards read in the meantime. - const tombstoned = await prisma.medicationIntakeEvent.findMany({ + const tombstoned = await db.medicationIntakeEvent.findMany({ where: tombstoneWhere, select: { scheduledFor: true }, }); @@ -708,331 +713,337 @@ export const PUT = apiHandler( tombstonedTo = row.scheduledFor; } } - await prisma.medicationIntakeEvent.updateMany({ + await db.medicationIntakeEvent.updateMany({ where: tombstoneWhere, data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // Recompute each affected rollup day (deduped via the user-tz day - // key). Best-effort like every other rollup write-hook: a failure - // is annotated, never blocks the schedule replace. - const staleDayKeys = new Set( - tombstoned.map((row) => - dayKeyForScheduledFor(row.scheduledFor, userTz), - ), - ); - for (const dayKey of staleDayKeys) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - userTz, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // Deduped via the user-tz day key. + for (const row of tombstoned) { + staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); } }; - // If schedules provided, replace all - if (schedules && normalisedSchedules) { - // v1.16.3 — effective dating. Before the wholesale replace below wipes - // the old rows, archive them as ONE revision covering - // `[previous validUntil | medication.createdAt, now)` — but only when a - // cadence-relevant field actually changes. A no-op edit (the Zeitplan - // tab echoing the same rows back) must not mint a phantom era. - const previousRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - const previousEntries = previousRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ); - replacedScheduleCount = previousRows.length; - replacedCadence = - previousRows.length > 0 - ? previousRows - .map((row) => - [ - row.timesOfDay, - row.rrule ?? "", - row.rollingIntervalDays !== null - ? `every ${row.rollingIntervalDays}d` - : "", - row.daysOfWeek ?? "", - ] - .filter((part) => part !== "") - .join(" "), - ) - .join(" | ") - : null; - - // v1.39.1 (#1033) — a stretch with intake tracking off expected - // nothing, so it archives with an empty payload whatever the rows - // said; and a tracking transition is itself an era boundary even when - // the rows stay the same. - const archivedEntries = wasTracked ? previousEntries : []; - if ( - previousRows.length > 0 && - (trackingChanged || - !wasTracked || - schedulesMateriallyDiffer( - previousEntries, - normalisedSchedules.map((n) => n.snapshot), - )) - ) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - // Chain from the ACTIVE boundary: a superseded row is an - // audit record whose `validUntil` may sit past its correction. - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: archivedEntries.length }, - }); - } else if ( - previousRows.length === 0 && - existing.asNeeded && - asNeeded === false && - normalisedSchedules.length > 0 - ) { - // v1.16.11 — flipping as-needed OFF. The medication carried ZERO - // schedule rows while as-needed, so the wholesale-replace archive - // above never fires — and without a revision the live era would - // start at the PREVIOUS revision's `validUntil` (or `createdAt`), - // retro-painting the schedule-less as-needed stretch with the NEW - // schedule's expected slots: every PRN day would read as missed. - // Archive an EMPTY era covering the as-needed stretch instead; an - // empty payload expands to zero schedules, so era-aware compliance - // expects nothing there — exactly the as-needed contract. - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: [] as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { schedule_revision_rows: 0 }, - }); - } - await tombstoneOpenSlots(); - await prisma.medicationSchedule.deleteMany({ - where: { medicationId: id }, - }); - } else if (trackingChanged) { - // v1.39.1 (#1033) — intake tracking switched without a schedule - // replace. Close the current era at this instant: switching OFF - // archives the schedule that was in force (the tracked stretch keeps - // its expected doses); switching back ON archives the untracked - // stretch with an empty payload, so compliance never counts it as - // missed and the live schedule, the projector and the reminder worker - // all resume from now. - const liveRows = await prisma.medicationSchedule.findMany({ - where: { medicationId: id }, - }); - if (liveRows.length > 0) { - const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - select: { validUntil: true }, - }); - const archivedEntries = wasTracked - ? liveRows.map((row) => - toRevisionPayloadEntry({ - timesOfDay: row.timesOfDay, - windowStart: row.windowStart, - windowEnd: row.windowEnd, - daysOfWeek: row.daysOfWeek, - rrule: row.rrule, - rollingIntervalDays: row.rollingIntervalDays, - scheduleType: row.scheduleType, - cyclicOnWeeks: row.cyclicOnWeeks, - cyclicOffWeeks: row.cyclicOffWeeks, - doseWindows: row.doseWindows, - label: row.label, - dose: row.dose, - reminderGraceMinutes: row.reminderGraceMinutes, - }), - ) - : []; - await prisma.medicationScheduleRevision.create({ - data: { - medicationId: id, - validFrom: lastRevision?.validUntil ?? existing.createdAt, - validUntil: new Date(), - payload: archivedEntries as unknown as Prisma.InputJsonValue, - }, - }); - annotate({ - action: { - name: "medication.schedule.revision_archived", - entity_type: "medication", - entity_id: id, - }, - meta: { - schedule_revision_rows: archivedEntries.length, - track_intake: willTrack, - }, - }); - } - } - - // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: - // the open placeholders of today and later go the way a schedule - // replace sends them (a replace in the same request already did it). - if (trackingChanged && !willTrack && !schedules) { - await tombstoneOpenSlots(); - } - - const baseUpdateData = { - ...(name !== undefined && { name }), - ...(dose !== undefined && { dose }), - ...(treatmentClass !== undefined && { treatmentClass }), - ...(dosesPerUnit !== undefined && { dosesPerUnit }), - // v1.16.10 — units consumed per dose. Field-by-field; the stamp on - // already-taken intake events freezes their recorded consumption. - ...(unitsPerDose !== undefined && { unitsPerDose }), - // v1.17.0 — reorder lead override. Field-by-field; `null` is a - // valid explicit value (clears the override → user-level default). - ...(reorderLeadDays !== undefined && { reorderLeadDays }), - ...(deliveryForm !== undefined && { deliveryForm }), - // v1.8.5 — injection-site tracking opt-in + per-medication allowed - // sites. Field-by-field; `false` / `[]` are valid explicit values - // (deactivate tracking / clear the per-med restriction). - ...(trackInjectionSites !== undefined && { trackInjectionSites }), - ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), - ...(active !== undefined && { active }), - ...(notificationsEnabled !== undefined && { notificationsEnabled }), - // v1.7.0 — iOS reminder flags, field-by-field. - ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), - ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), - // v1.9.0 — optional drug-classification codes (ATC / RxNorm). - // Field-by-field; `null` is a valid explicit value (clears the code). - ...(atcCode !== undefined && { atcCode }), - ...(rxNormCode !== undefined && { rxNormCode }), - // v1.5 scheduling primitives — pass-through when supplied. - // `startsOn` / `endsOn` are `Date | null | undefined` (the - // schema lets the user clear them explicitly with null). - // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the - // course writer below writes them together with the course they - // project, in one transaction, so the row and its courses cannot - // disagree if one of the two writes fails. - ...(oneShot !== undefined && { oneShot }), - // v1.16.11 — as-needed flag, field-by-field (the invariants above - // already guaranteed the medication ends schedule-consistent). - ...(asNeeded !== undefined && { asNeeded }), - // v1.39.1 (#1033) — intake tracking, field-by-field. - ...(trackIntake !== undefined && { trackIntake }), - ...(normalisedSchedules && { - schedules: { - create: normalisedSchedules.map((n) => n.createData), - }, - }), - }; - - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); - } - } - + // v1.40 — everything the PUT writes to the medication, its schedule and + // its course commits as one transaction. The course write can still be + // refused after the check above (another request wrote a course in + // between); with the row update committed first, the client got the + // refusal while the name, the schedule and `oneShot` were already + // changed. Now a refusal rolls all of it back. The rollup recompute and + // the grace bridge stay outside: both read or write what this commit + // settles, and neither may undo it. let medication; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { - try { - medication = await prisma.medication.update({ + try { + medication = await prisma.$transaction(async (tx) => { + // If schedules provided, replace all + if (schedules && normalisedSchedules) { + // v1.16.3 — effective dating. Before the wholesale replace below wipes + // the old rows, archive them as ONE revision covering + // `[previous validUntil | medication.createdAt, now)` — but only when a + // cadence-relevant field actually changes. A no-op edit (the Zeitplan + // tab echoing the same rows back) must not mint a phantom era. + const previousRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + const previousEntries = previousRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), + ); + replacedScheduleCount = previousRows.length; + replacedCadence = + previousRows.length > 0 + ? previousRows + .map((row) => + [ + row.timesOfDay, + row.rrule ?? "", + row.rollingIntervalDays !== null + ? `every ${row.rollingIntervalDays}d` + : "", + row.daysOfWeek ?? "", + ] + .filter((part) => part !== "") + .join(" "), + ) + .join(" | ") + : null; + + // v1.39.1 (#1033) — a stretch with intake tracking off expected + // nothing, so it archives with an empty payload whatever the rows + // said; and a tracking transition is itself an era boundary even when + // the rows stay the same. + const archivedEntries = wasTracked ? previousEntries : []; + if ( + previousRows.length > 0 && + (trackingChanged || + !wasTracked || + schedulesMateriallyDiffer( + previousEntries, + normalisedSchedules.map((n) => n.snapshot), + )) + ) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: archivedEntries.length }, + }); + } else if ( + previousRows.length === 0 && + existing.asNeeded && + asNeeded === false && + normalisedSchedules.length > 0 + ) { + // v1.16.11 — flipping as-needed OFF. The medication carried ZERO + // schedule rows while as-needed, so the wholesale-replace archive + // above never fires — and without a revision the live era would + // start at the PREVIOUS revision's `validUntil` (or `createdAt`), + // retro-painting the schedule-less as-needed stretch with the NEW + // schedule's expected slots: every PRN day would read as missed. + // Archive an EMPTY era covering the as-needed stretch instead; an + // empty payload expands to zero schedules, so era-aware compliance + // expects nothing there — exactly the as-needed contract. + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: [] as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { schedule_revision_rows: 0 }, + }); + } + await tombstoneOpenSlots(tx); + await tx.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); + } else if (trackingChanged) { + // v1.39.1 (#1033) — intake tracking switched without a schedule + // replace. Close the current era at this instant: switching OFF + // archives the schedule that was in force (the tracked stretch keeps + // its expected doses); switching back ON archives the untracked + // stretch with an empty payload, so compliance never counts it as + // missed and the live schedule, the projector and the reminder worker + // all resume from now. + const liveRows = await tx.medicationSchedule.findMany({ + where: { medicationId: id }, + }); + if (liveRows.length > 0) { + const lastRevision = await tx.medicationScheduleRevision.findFirst({ + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + select: { validUntil: true }, + }); + const archivedEntries = wasTracked + ? liveRows.map((row) => + toRevisionPayloadEntry({ + timesOfDay: row.timesOfDay, + windowStart: row.windowStart, + windowEnd: row.windowEnd, + daysOfWeek: row.daysOfWeek, + rrule: row.rrule, + rollingIntervalDays: row.rollingIntervalDays, + scheduleType: row.scheduleType, + cyclicOnWeeks: row.cyclicOnWeeks, + cyclicOffWeeks: row.cyclicOffWeeks, + doseWindows: row.doseWindows, + label: row.label, + dose: row.dose, + reminderGraceMinutes: row.reminderGraceMinutes, + }), + ) + : []; + await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom: lastRevision?.validUntil ?? existing.createdAt, + validUntil: new Date(), + payload: archivedEntries as unknown as Prisma.InputJsonValue, + }, + }); + annotate({ + action: { + name: "medication.schedule.revision_archived", + entity_type: "medication", + entity_id: id, + }, + meta: { + schedule_revision_rows: archivedEntries.length, + track_intake: willTrack, + }, + }); + } + } + + // v1.39.1 (#1033) — switching intake tracking off leaves nothing due: + // the open placeholders of today and later go the way a schedule + // replace sends them (a replace in the same request already did it). + if (trackingChanged && !willTrack && !schedules) { + await tombstoneOpenSlots(tx); + } + + const baseUpdateData = { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(treatmentClass !== undefined && { treatmentClass }), + ...(dosesPerUnit !== undefined && { dosesPerUnit }), + // v1.16.10 — units consumed per dose. Field-by-field; the stamp on + // already-taken intake events freezes their recorded consumption. + ...(unitsPerDose !== undefined && { unitsPerDose }), + // v1.17.0 — reorder lead override. Field-by-field; `null` is a + // valid explicit value (clears the override → user-level default). + ...(reorderLeadDays !== undefined && { reorderLeadDays }), + ...(deliveryForm !== undefined && { deliveryForm }), + // v1.8.5 — injection-site tracking opt-in + per-medication allowed + // sites. Field-by-field; `false` / `[]` are valid explicit values + // (deactivate tracking / clear the per-med restriction). + ...(trackInjectionSites !== undefined && { trackInjectionSites }), + ...(allowedInjectionSites !== undefined && { allowedInjectionSites }), + ...(active !== undefined && { active }), + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + // v1.7.0 — iOS reminder flags, field-by-field. + ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), + ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), + // v1.5 scheduling primitives — pass-through when supplied. + // `startsOn` / `endsOn` are `Date | null | undefined` (the + // schema lets the user clear them explicitly with null). + // v1.40 (#1024) — `startsOn` / `endsOn` are not written here: the + // course writer below writes them together with the course they + // project, in one transaction, so the row and its courses cannot + // disagree if one of the two writes fails. + ...(oneShot !== undefined && { oneShot }), + // v1.16.11 — as-needed flag, field-by-field (the invariants above + // already guaranteed the medication ends schedule-consistent). + ...(asNeeded !== undefined && { asNeeded }), + // v1.39.1 (#1033) — intake tracking, field-by-field. + ...(trackIntake !== undefined && { trackIntake }), + ...(normalisedSchedules && { + schedules: { + create: normalisedSchedules.map((n) => n.createData), + }, + }), + }; + + const updated = await tx.medication.update({ where: { id }, - data: candidate, + data: { ...baseUpdateData, ...pausedAtPatch }, include: { schedules: true }, }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; - } - } - if (!medication) throw lastUpdateErr; - // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a - // single live marker that resume clears, so the paused window is - // irrecoverable once resumed and every paused day's slot collapses to - // "missed" in the compliance denominator. Mirror the live transition - // onto an additive era record the rate paths read: a pause opens an era - // (resumedAt null), a resume closes the latest open era. `userId` comes - // from the authenticated session; the data object is built field-by-field. - if (active === false && existing.active) { - await prisma.medicationPauseEra.create({ - data: { - medicationId: id, - userId: user.id, - pausedAt: new Date(), - resumedAt: null, - }, - }); - } else if (active === true && !existing.active) { - await prisma.medicationPauseEra.updateMany({ - where: { medicationId: id, userId: user.id, resumedAt: null }, - data: { resumedAt: new Date() }, + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await tx.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await tx.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + } + + // v1.40 (#1024) — write the window onto the latest course and + // project it back, then serve the projected row. + if (touchesWindow) { + const projected = await setCurrentWindow( + { + userId: user.id, + medicationId: id, + timeZone: courseTz, + ...nextWindow, + }, + tx, + ); + return { ...updated, ...projected }; + } + return updated; }); + } catch (err) { + // A concurrent course write landed between the check above and the + // course write. The transaction rolled back: nothing was written. + if (err instanceof CourseWriteError) { + const r = courseRefusalResponse(err.refusal); + return apiError(r.message, r.status, { errorCode: r.errorCode }); + } + throw err; + } + + // Recompute each rollup day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every other rollup + // write-hook: a failure is annotated, never fails the edit. + for (const dayKey of staleRollupDays) { + try { + await recomputeMedicationComplianceForDay( + user.id, + id, + dayKey, + user.timezone || DEFAULT_TIMEZONE, + ); + } catch (err) { + annotate({ + meta: { + medication_compliance_rollup_failed: true, + medication_compliance_rollup_error: + err instanceof Error ? err.message : String(err), + }, + }); + } } // Apply the v1.5.5 primary-schedule grace bridge after the @@ -1055,29 +1066,6 @@ export const PUT = apiHandler( } } - // v1.40 (#1024) — write the window onto the latest course and project it - // back, then serve the projected row. - if (touchesWindow) { - let projected; - try { - projected = await setCurrentWindow({ - userId: user.id, - medicationId: id, - timeZone: courseTz, - ...nextWindow, - }); - } catch (err) { - // A concurrent course write landed between the check above and this - // transaction. Nothing of the window was written. - if (err instanceof CourseWriteError) { - const r = courseRefusalResponse(err.refusal); - return apiError(r.message, r.status, { errorCode: r.errorCode }); - } - throw err; - } - medication = { ...medication, ...projected }; - } - // v1.40 (#1041) — an iPhone app build that predates custom categories // decodes an unknown category as OTHER and sends it back on every edit, // which would overwrite the person's own category with OTHER. A native diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 9c5e5809b..0eb415494 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -623,3 +623,96 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => { } }); }); + +// #1126 — the Local and gateway entries' reasoning setting, written from the +// provider forms onto the chain entry and read back from it. +describe("PATCH /api/user/ai-provider — reasoning setting (#1126)", () => { + const patch = PATCH as (req: Request) => Promise; + function storedChain(chain: unknown) { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProviderChain: chain, + } as never); + } + function written() { + return vi.mocked(prisma.user.update).mock.calls[0]?.[0].data as + Record | undefined; + } + + it("stores Off on the local entry of a chain that was never customised", async () => { + storedChain(null); + const res = await patch( + patchRequest({ provider: "LOCAL", localReasoningEffort: "none" }), + ); + expect(res.status).toBe(200); + const chain = written()?.aiProviderChain as Array<{ + providerType: string; + reasoningEffort?: string; + }>; + expect(chain.find((e) => e.providerType === "local")).toMatchObject({ + enabled: true, + reasoningEffort: "none", + }); + expect(chain.filter((e) => e.reasoningEffort)).toHaveLength(1); + }); + + it("holds the gateway's value on a disabled entry when the chain lacks it", async () => { + storedChain([{ providerType: "local", priority: 1, enabled: true }]); + await patch(patchRequest({ compatReasoningEffort: "high" })); + expect(written()?.aiProviderChain).toEqual([ + { providerType: "local", priority: 1, enabled: true }, + { + providerType: "openai-compatible", + priority: 2, + enabled: false, + reasoningEffort: "high", + }, + ]); + }); + + it("does not touch the chain when Default is restated on a default chain", async () => { + storedChain(null); + await patch( + patchRequest({ provider: "LOCAL", localReasoningEffort: null }), + ); + expect(written()).toEqual({ aiProvider: "LOCAL" }); + }); + + it("answers 200 for a body that only restates Default", async () => { + storedChain(null); + const res = await patch(patchRequest({ localReasoningEffort: null })); + expect(res.status).toBe(200); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a value outside the five", async () => { + const res = await patch(patchRequest({ localReasoningEffort: "max" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/user/ai-provider — reasoning setting (#1126)", () => { + it("reads both values from the chain, null for Default", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiProvider: "LOCAL", + aiProviderChain: [ + { + providerType: "local", + priority: 1, + enabled: true, + reasoningEffort: "none", + }, + ], + } as never); + vi.mocked(resolveProviderAvailability).mockResolvedValue({ + aiAvailable: true, + managedBy: "local", + }); + const res = await (GET as () => Promise)(); + const body = (await res.json()) as { + data: { localReasoningEffort: unknown; compatReasoningEffort: unknown }; + }; + expect(body.data.localReasoningEffort).toBe("none"); + expect(body.data.compatReasoningEffort).toBeNull(); + }); +}); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index 7f19b619b..1b976d5ec 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -20,6 +20,12 @@ import { } from "@/lib/sharing/provider-work-authority"; import { annotate } from "@/lib/logging/context"; import { aiProviderPatchSchema } from "@/lib/validations/ai-provider"; +import { + chainWithReasoningEffort, + parseProviderChain, + serializeProviderChain, +} from "@/lib/ai/provider-chain"; +import { reasoningEffortFor } from "@/lib/ai/reasoning-effort"; export const dynamic = "force-dynamic"; @@ -42,6 +48,8 @@ export const GET = apiHandler(async () => { aiCompatModel: true, // v1.22 (#89) aiResponseTimeoutSeconds: true, + // #1126 — the reasoning settings live on the chain entries. + aiProviderChain: true, // v1.38.19 — a managed profile is never the party that gives // consent for itself; the offer below has to know. managedProfileAt: true, @@ -141,6 +149,13 @@ export const GET = apiHandler(async () => { hasCompatKey: Boolean(u?.aiCompatKeyEncrypted), // v1.22 (#89) — per-user response timeout, in seconds (null = default). responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null, + // #1126 — the Local and gateway entries' reasoning settings (null = + // Default), read from the chain where they are stored. + localReasoningEffort: reasoningEffortFor(u?.aiProviderChain, "local"), + compatReasoningEffort: reasoningEffortFor( + u?.aiProviderChain, + "openai-compatible", + ), // v1.38.19 — see the block above. A tri-state and two booleans; // no count, no timestamp and no other account is inferable from them. serverProviderHealth, @@ -297,7 +312,45 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiResponseTimeoutSeconds = body.responseTimeoutSeconds; } - if (Object.keys(updates).length === 0) { + // ── #1126 — reasoning settings of the Local and gateway entries ── + // Stored on the entry in `aiProviderChain` rather than in a column of their + // own, so the setting is per entry and a chain can mix a thinking model with + // one that does not know the field. See `chainWithReasoningEffort` for how a + // default chain, or a provider absent from the chain, holds the value. + const reasoningTouched = + body.localReasoningEffort !== undefined || + body.compatReasoningEffort !== undefined; + if (reasoningTouched) { + const current = await prisma.user.findUnique({ + where: { id: user.id }, + select: { aiProviderChain: true }, + }); + let chain: unknown = current?.aiProviderChain ?? null; + if (body.localReasoningEffort !== undefined) { + chain = chainWithReasoningEffort( + chain, + "local", + body.localReasoningEffort, + ); + } + if (body.compatReasoningEffort !== undefined) { + chain = chainWithReasoningEffort( + chain, + "openai-compatible", + body.compatReasoningEffort, + ); + } + // Written only when it changed: restating Default on a chain that was + // never customised must not freeze today's default chain into the row. + const before = serializeProviderChain( + parseProviderChain(current?.aiProviderChain ?? null), + ); + if (serializeProviderChain(parseProviderChain(chain)) !== before) { + updates.aiProviderChain = chain; + } + } + + if (Object.keys(updates).length === 0 && !reasoningTouched) { return apiError("No valid fields", 422, { errorCode: "ai_provider.no_fields", }); @@ -317,7 +370,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiBaseUrl = null; } - await prisma.user.update({ where: { id: user.id }, data: updates }); + if (Object.keys(updates).length > 0) { + await prisma.user.update({ where: { id: user.id }, data: updates }); + } return apiSuccess({ updated: true }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 3a0550cc5..22b557d71 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -464,6 +464,18 @@ function catalogueLabel( if (catalogue === "symptomEpisodeReference") { return t("admin.section.backups.restoreSkippedSymptomEpisodeReference"); } + if (catalogue === "visitCiphertext") { + return t("admin.section.backups.restoreSkippedVisitCiphertext"); + } + if (catalogue === "vaccinationCiphertext") { + return t("admin.section.backups.restoreSkippedVaccinationCiphertext"); + } + if (catalogue === "moodLabelCiphertext") { + return t("admin.section.backups.restoreSkippedMoodLabelCiphertext"); + } + if (catalogue === "cycleCiphertext") { + return t("admin.section.backups.restoreSkippedCycleCiphertext"); + } if (catalogue === "medicationTarget") { return t("admin.section.backups.restoreSkippedMedicationTarget"); } diff --git a/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx new file mode 100644 index 000000000..0f2f2efd2 --- /dev/null +++ b/src/components/settings/ai/__tests__/reasoning-effort-field.test.tsx @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import en from "../../../../../messages/en.json"; +import { CompatProviderForm } from "../compat-provider-form"; +import { LocalProviderForm } from "../local-provider-form"; +import type { UserAIProvider } from "../shared"; + +/** + * #1126 — the Reasoning select on the Local and OpenAI-compatible forms. It + * sits right under the model field, offers exactly Default / Off / Low / + * Medium / High, and seeds from the value the server stored on the entry. + * Saving it is pinned end to end in `e2e/ai-provider-dropdown.spec.ts`. + */ + +function provider(overrides: Partial): UserAIProvider { + return { + provider: "LOCAL", + model: "llama3:8b", + baseUrl: "http://ollama.example.org:11434/v1", + hasAnthropicKey: false, + anthropicKeyPreview: null, + hasLocalKey: false, + hasOpenaiKey: false, + openaiKeyPreview: null, + compatBaseUrl: "https://gateway.example.org/v1", + compatModel: "qwen3", + hasCompatKey: false, + responseTimeoutSeconds: null, + ...overrides, + }; +} + +function render(node: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +/** The `", start)); +} + +const OPTION_VALUES = ["", "none", "low", "medium", "high"]; + +describe.each([ + { + name: "Local", + id: "ai-local-reasoning", + after: "ai-local-model", + form: (p: UserAIProvider) => , + stored: (v: UserAIProvider["localReasoningEffort"]) => ({ + localReasoningEffort: v, + }), + }, + { + name: "OpenAI-compatible", + id: "ai-compat-reasoning", + after: "ai-compat-model", + form: (p: UserAIProvider) => , + stored: (v: UserAIProvider["compatReasoningEffort"]) => ({ + compatReasoningEffort: v, + provider: "OPENAI_COMPATIBLE", + }), + }, +])("$name form", ({ id, after, form, stored }) => { + it("offers Default, Off, Low, Medium and High under the model field", () => { + const html = render(form(provider({}))); + const select = selectMarkup(html, id); + expect( + [...select.matchAll(/`); + } + expect(html).toContain(` { + const select = selectMarkup(render(form(provider({}))), id); + expect(select).toMatch(/