diff --git a/packages/capture-recovery/README.md b/packages/capture-recovery/README.md new file mode 100644 index 0000000..69a262f --- /dev/null +++ b/packages/capture-recovery/README.md @@ -0,0 +1,144 @@ +# @journal/capture-recovery + +Interrupted-capture recovery for the Shared Child Journal: a pure, deterministic +state machine + truthful UI projection + replayable fixtures covering +**interruptions (call / app switch), backgrounding with process death, network +loss, and duplicate submission** — prototyped one-handed for mom, dad, and +invited caregivers. + +Bounded direct-worker extension of the canonical contract +(`art_I2TCG08V` v0.2). It **imports** `CaptureId`, `ExtractionAttempt`, and +`ExtractionStatus` from `@journal/domain` and **modifies nothing** in the +existing domain, entry, event, extraction, ui, security, or evaluation +packages. Proposal target: fold into contract v0.3 if accepted. + +## Why events, not states + +Interruption, backgrounding, network loss, and duplicate submission are +**events**, not phases. The persisted state is always sufficient to resume, so +recovery is just rehydration — no special "recovering" phase, no re-processing. + +## Phases (truthful by construction) + +| Phase | Means | Can never mean | +| --- | --- | --- | +| `drafting` | capture in progress; raw transcript persisted on every change | — | +| `pending` | submit in flight; server outcome genuinely unknown | "sent" / "saved" | +| `saved` | server ack received for the current submission | optimistic | +| `failed` | explicit server rejection with a reason; raw transcript intact | network guess without a rejection | +| `discarded` | caregiver explicitly discarded; receipt kept, raw cleared | silent absence | + +There is no optimistic `saving` phase. `saved` is reachable **only** through +`SubmitAccepted` (idempotent: a repeated accept returns the same state +object). During a network outage a pending capture stays `pending` — the +client never fabricates a failure or a save. + +## Events + +`TranscriptChanged{at,text}` · `Interrupted{at,kind: call|app-switch|background|process-death}` · +`Resumed{at}` · `SubmitRequested{at,submissionId}` · `RetryRequested{at}` · +`SubmitAccepted{at}` · `SubmitRejected{at,attempt,reason: network|server|unauthorized|invalid}` · +`ExtractionResultArrived{at,attempt,outcome: structured|failed}` · +`NetworkLost{at}` · `NetworkRestored{at}` · `DiscardRequested{at}` + +The reducer is total: unexpected event/phase combinations are recorded as +bounded anomalies (cap 10) instead of throwing — a recovery path must never +crash the capture it is recovering. Timestamps come from events only, so +fixtures replay identically anywhere. + +## Invariants (each pinned by a test) + +1. **Raw-before-events** — only `TranscriptChanged` and an explicit + `DiscardRequested` may touch `rawTranscript`; every other transition + preserves it verbatim. +2. **Attempt monotonicity** — `attempt` never decreases (canonical envelope). +3. **Stable submission identity** — `submissionId` is assigned at first submit + and reused across retries; a foreign id while active is an anomaly. +4. **No optimistic truth** — `saved` only via `SubmitAccepted`; duplicate + submissions suppressed while pending AND after save. +5. **Stale-result suppression** — results/rejections with + `attempt < current` are counted and discarded, never merged (contract + v0.2 rule b). +6. **Discard leaves a receipt** — raw text cleared; the receipt (time, + length, attempt, submissionId) survives, keeping a discarded capture a + visible receipt rather than a missing log (missing-log vs zero-care). +7. **Audience stays out** — phases describe delivery of the capture, never + who may see it; `Entry.visibility` (`draft|published`) remains the only + per-entry publication state, and household grants remain the audience + dimension (contract v0.2, two independent dimensions). + +## One-handed truthful UI projection (`bannerFor`) + +Every banner is **derived from persisted state** — never in-memory optimism. +Bottom-anchored (thumb reach), ≤ 2 actions, single 48pt primary target. + +| Persisted state | Banner | +| --- | --- | +| `drafting` + interrupted | info — "Draft is safe on this device" / primary **Keep recording** | +| `pending` | info — "Sending…" / "Not saved to the journal yet. A copy stays on this device." | +| `pending` + network lost | warning — "Waiting for network" / no actions (nothing to truthfully tap) | +| `failed` network/server | warning — "Couldn't send — …" / **Try again** + **Discard** | +| `failed` unauthorized | warning — "Sign-in needed" / **Sign in & send** + **Discard** | +| `failed` invalid | warning — "Needs a fix before sending" / **Review** + **Discard** | +| `saved` | banner hidden; success chip "Saved to the journal" | +| `discarded` | neutral receipt — "Discarded — nothing was added to the journal" | + +## Storage contract + +Save-on-transition: the caller persists after **every** `reduce` (the reducer +itself is pure and does no I/O), so a process death at any instant loses +nothing. `CaptureStorage` exposes `load` (cold-start rehydration), +`listUnresolved` (drafting/pending/failed — the reconnect sweep reconciles +these by `submissionId`), and `listReceipts` (discarded — receipts, not +unresolved work). Durable adapters must read through `decodeStoredState` +(fail-closed decode before use). + +## Fixture catalog (`fixtures/`, replayed by `test/recovery.test.ts`) + +| # | Fixture | Scenario | +| --- | --- | --- | +| 01 | `interruption-mid-draft` | call interrupts; reload mid-draft; raw verbatim; completes → saved | +| 02 | `backgrounding-process-death` | background + process death; cold start rehydrates raw + attribution → saved | +| 03 | `network-loss-pending` | outage after submit; stays pending (no fabrication); ack after restore → saved | +| 04 | `failed-retry-success` | explicit rejection → failed; one-handed retry (same submissionId, attempt+1) → saved | +| 05 | `duplicate-submission` | double tap + late re-tap; 2 duplicates suppressed; duplicate ack idempotent | +| 06 | `stale-result` | superseded attempt-0 result suppressed, never merged; attempt-1 applies | +| 07 | `discard-receipt` | explicit discard; receipt survives reload; resurrection attempt is an anomaly | + +```bash +# from the repo root +pnpm install +pnpm exec turbo run build typecheck test --filter=@journal/capture-recovery +``` + +## Ownership and handoff + +This package is a **proposal, implemented as code**: state-transition +contracts + fixtures, ready to be consumed by the lanes that own the +integration. + +- **Slot 22 — Offline capture / reconnect / concurrent contributions** + (`todo_N1ukao5q`, Journal & Caregiver Coordination): adopt this machine as + the capture-side recovery kernel; add the durable `CaptureStorage` adapter + (AsyncStorage/SQLite) and the pending-reconciliation protocol. +- **Voice/photo capture slice (merged PR #8)**: wire RN app-lifecycle events + to `Interrupted`/`Resumed`, the speech layer to `TranscriptChanged`, and + the Convex mutation to `SubmitRequested`/`SubmitAccepted`/`SubmitRejected`. +- **Review-UX candidates (slots 11–13)**: `bannerFor`/`RecoveryBanner` is the + projection contract to render; copy strings are pinned by tests. +- **Contract thread (`art_I2TCG08V`)**: propose folding the recovery state + schema into contract v0.3 — no Entry/Event/field changes are required by + this package. + +## Known limitations (honest) + +- No live React Native component is included: the on-screen banner rendering + and the AsyncStorage adapter are deliberately left to the owning lanes + (above); what ships here is the behavior contract they consume. +- Pending-reconciliation (resolving a `pending` capture against the server by + `submissionId` after a cold start) is a named integration responsibility + here, not an implemented Convex function — it belongs with slot 02's + operation contracts and slot 22's implementation. +- Attachments (photo recovery, large blobs) are out of scope for this slice. +- The reducer trusts the caller to persist on transition; runtime enforcement + belongs to the storage adapter integration. diff --git a/packages/capture-recovery/fixtures/01-interruption-mid-draft.json b/packages/capture-recovery/fixtures/01-interruption-mid-draft.json new file mode 100644 index 0000000..37fd67b --- /dev/null +++ b/packages/capture-recovery/fixtures/01-interruption-mid-draft.json @@ -0,0 +1,24 @@ +{ + "id": "interruption-mid-draft", + "kind": "interruption-recovery", + "description": "A phone call interrupts mid-dictation. The raw transcript was persisted before the interruption, so after reload the draft is recoverable verbatim and can be completed one-handed.", + "capture": { "captureId": "cap-recover-001", "authorId": "caregiver-1", "at": 1789563600000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789563601000, "text": "Ava had a huge meltdown" } }, + { "event": { "_tag": "TranscriptChanged", "at": 1789563602000, "text": "Ava had a huge meltdown after preschool" } }, + { "event": { "_tag": "Interrupted", "at": 1789563603000, "kind": "call" } }, + { "reload": true }, + { "event": { "_tag": "Resumed", "at": 1789563630000 } }, + { "event": { "_tag": "TranscriptChanged", "at": 1789563631000, "text": "Ava had a huge meltdown after preschool, calmed by her plush rabbit." } }, + { "event": { "_tag": "SubmitRequested", "at": 1789563640000, "submissionId": "sub-recover-001" } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789563645000 } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "rawTranscript": "Ava had a huge meltdown after preschool, calmed by her plush rabbit.", + "submissionId": "sub-recover-001", + "attempt": 0 + } +} diff --git a/packages/capture-recovery/fixtures/02-backgrounding-process-death.json b/packages/capture-recovery/fixtures/02-backgrounding-process-death.json new file mode 100644 index 0000000..16b9bc9 --- /dev/null +++ b/packages/capture-recovery/fixtures/02-backgrounding-process-death.json @@ -0,0 +1,21 @@ +{ + "id": "backgrounding-process-death", + "kind": "interruption-recovery", + "description": "The app is backgrounded mid-dictation and the OS kills the process. On relaunch, the draft rehydrates from persisted state alone — raw text intact, author attribution intact — and the caregiver completes the capture.", + "capture": { "captureId": "cap-recover-002", "authorId": "caregiver-1", "at": 1789563700000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789563701000, "text": "Ava ate all of her pasta at dinner" } }, + { "event": { "_tag": "Interrupted", "at": 1789563702000, "kind": "background" } }, + { "reload": true }, + { "event": { "_tag": "Resumed", "at": 1789563760000 } }, + { "event": { "_tag": "SubmitRequested", "at": 1789563761000, "submissionId": "sub-recover-002" } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789563764000 } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "rawTranscript": "Ava ate all of her pasta at dinner", + "attempt": 0 + } +} diff --git a/packages/capture-recovery/fixtures/03-network-loss-pending.json b/packages/capture-recovery/fixtures/03-network-loss-pending.json new file mode 100644 index 0000000..19adca1 --- /dev/null +++ b/packages/capture-recovery/fixtures/03-network-loss-pending.json @@ -0,0 +1,18 @@ +{ + "id": "network-loss-pending", + "kind": "network-loss", + "description": "The network drops right after submit. The truthful contract: the capture stays pending — the server outcome is genuinely unknown and the client must not fabricate a failure or a save. When connectivity returns, the server ack (it completed despite the disconnect) lands and the capture is saved.", + "capture": { "captureId": "cap-recover-003", "authorId": "caregiver-1", "at": 1789563800000 }, + "steps": [ + { "event": { "_tag": "SubmitRequested", "at": 1789563801000, "submissionId": "sub-recover-003" } }, + { "event": { "_tag": "NetworkLost", "at": 1789563802000 } }, + { "event": { "_tag": "NetworkRestored", "at": 1789563840000 } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789563841000 } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "attempt": 0 + } +} diff --git a/packages/capture-recovery/fixtures/04-failed-retry-success.json b/packages/capture-recovery/fixtures/04-failed-retry-success.json new file mode 100644 index 0000000..b2bdbd8 --- /dev/null +++ b/packages/capture-recovery/fixtures/04-failed-retry-success.json @@ -0,0 +1,24 @@ +{ + "id": "failed-retry-success", + "kind": "network-loss", + "description": "A submission is explicitly rejected (network). The capture moves to failed with the raw transcript intact; a one-handed retry reuses the SAME submissionId (idempotent server replay) and bumps the extraction attempt (canonical envelope). The retry is accepted.", + "capture": { "captureId": "cap-recover-004", "authorId": "caregiver-1", "at": 1789563900000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789563901000, "text": "Mia tried the big slide all by herself" } }, + { "event": { "_tag": "SubmitRequested", "at": 1789563902000, "submissionId": "sub-recover-004" } }, + { "event": { "_tag": "SubmitRejected", "at": 1789563903000, "attempt": 0, "reason": "network" } }, + { "event": { "_tag": "NetworkRestored", "at": 1789563933000 } }, + { "event": { "_tag": "RetryRequested", "at": 1789563934000 } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789563937000 } }, + { "event": { "_tag": "ExtractionResultArrived", "at": 1789563940000, "attempt": 1, "outcome": "structured" } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "rawTranscript": "Mia tried the big slide all by herself", + "submissionId": "sub-recover-004", + "attempt": 1, + "extractionStatus": "structured" + } +} diff --git a/packages/capture-recovery/fixtures/05-duplicate-submission.json b/packages/capture-recovery/fixtures/05-duplicate-submission.json new file mode 100644 index 0000000..2d56436 --- /dev/null +++ b/packages/capture-recovery/fixtures/05-duplicate-submission.json @@ -0,0 +1,22 @@ +{ + "id": "duplicate-submission", + "kind": "duplicate-submission", + "description": "Double-tap and late re-tap submit the same capture three times with one stable submissionId. The machine suppresses duplicates while pending AND after save; duplicate server acks are idempotent no-ops. Exactly one saved capture results.", + "capture": { "captureId": "cap-recover-005", "authorId": "caregiver-1", "at": 1789564000000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789564001000, "text": "Leo had two naps at school today" } }, + { "event": { "_tag": "SubmitRequested", "at": 1789564002000, "submissionId": "sub-recover-005" } }, + { "event": { "_tag": "SubmitRequested", "at": 1789564002100, "submissionId": "sub-recover-005" } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789564003000 } }, + { "event": { "_tag": "SubmitRequested", "at": 1789564003100, "submissionId": "sub-recover-005" } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789564004000 } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "rawTranscript": "Leo had two naps at school today", + "duplicateSubmissionsSuppressed": 2, + "attempt": 0 + } +} diff --git a/packages/capture-recovery/fixtures/06-stale-result.json b/packages/capture-recovery/fixtures/06-stale-result.json new file mode 100644 index 0000000..2c8398c --- /dev/null +++ b/packages/capture-recovery/fixtures/06-stale-result.json @@ -0,0 +1,23 @@ +{ + "id": "stale-result", + "kind": "stale-result", + "description": "After a failed attempt 0 the caregiver retries (attempt 1). The superseded attempt 0 extraction result arrives late and is suppressed — discarded, never merged (contract v0.2 rule b). The latest attempt 1 result applies normally.", + "capture": { "captureId": "cap-recover-006", "authorId": "caregiver-1", "at": 1789564100000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789564101000, "text": "Nina was fussy before her nap" } }, + { "event": { "_tag": "SubmitRequested", "at": 1789564102000, "submissionId": "sub-recover-006" } }, + { "event": { "_tag": "SubmitRejected", "at": 1789564103000, "attempt": 0, "reason": "network" } }, + { "event": { "_tag": "RetryRequested", "at": 1789564134000 } }, + { "event": { "_tag": "ExtractionResultArrived", "at": 1789564135000, "attempt": 0, "outcome": "structured" } }, + { "event": { "_tag": "SubmitAccepted", "at": 1789564136000 } }, + { "event": { "_tag": "ExtractionResultArrived", "at": 1789564140000, "attempt": 1, "outcome": "structured" } } + ], + "expect": { + "phase": "saved", + "tone": "success", + "bannerVisible": false, + "staleResultsSuppressed": 1, + "attempt": 1, + "extractionStatus": "structured" + } +} diff --git a/packages/capture-recovery/fixtures/07-discard-receipt.json b/packages/capture-recovery/fixtures/07-discard-receipt.json new file mode 100644 index 0000000..7036960 --- /dev/null +++ b/packages/capture-recovery/fixtures/07-discard-receipt.json @@ -0,0 +1,21 @@ +{ + "id": "discard-receipt", + "kind": "discard-receipt", + "description": "The caregiver explicitly discards a sensitive capture. The raw text is cleared; a receipt (time, length, attempt, submissionId) remains so a discarded capture is a visible receipt — never silent absence — and the missing-log vs zero-care distinction is preserved. Post-discard edits are anomalies, not resurrections.", + "capture": { "captureId": "cap-recover-007", "authorId": "caregiver-1", "at": 1789564200000 }, + "steps": [ + { "event": { "_tag": "TranscriptChanged", "at": 1789564201000, "text": "Ava bit her friend at pickup, we talked about it" } }, + { "event": { "_tag": "DiscardRequested", "at": 1789564202000 } }, + { "reload": true }, + { "event": { "_tag": "TranscriptChanged", "at": 1789564260000, "text": "attempted resurrection" } } + ], + "expect": { + "phase": "discarded", + "tone": "neutral", + "title": "Discarded — nothing was added to the journal", + "bannerVisible": true, + "hasReceipt": true, + "rawCleared": true, + "anomalies": 1 + } +} diff --git a/packages/capture-recovery/package.json b/packages/capture-recovery/package.json new file mode 100644 index 0000000..ec108de --- /dev/null +++ b/packages/capture-recovery/package.json @@ -0,0 +1,26 @@ +{ + "name": "@journal/capture-recovery", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Interrupted-capture recovery: pure state machine, truthful saved/pending/failed contract, replayable fixtures", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.test.json --noEmit", + "test": "bun test" + }, + "dependencies": { + "@journal/domain": "workspace:*", + "effect": "4.0.0-rc.115" + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "typescript": "~6.0.3" + } +} diff --git a/packages/capture-recovery/src/banner.ts b/packages/capture-recovery/src/banner.ts new file mode 100644 index 0000000..0154b51 --- /dev/null +++ b/packages/capture-recovery/src/banner.ts @@ -0,0 +1,138 @@ +import { Schema } from "effect" +import type { CaptureRecovery } from "./state.js" + +/** + * UI projection contract: everything the one-handed recovery surface shows is + * DERIVED here from persisted state — never from in-memory optimism. If a + * banner says "safe on this device", the raw transcript is persisted; if it + * says nothing about the server, the server outcome is genuinely unknown. + * + * One-handed encoding: the banner is bottom-anchored inside thumb reach, + * exposes at most two actions, and the single primary action meets the 48pt + * touch-target floor. No state offers more than one thing to tap first. + */ +export const BannerTone = Schema.Literals(["info", "warning", "success", "neutral"]) +export type BannerTone = typeof BannerTone["Type"] + +export const RecoveryAction = Schema.Literals(["resume", "retry", "discard", "review"]) +export type RecoveryAction = typeof RecoveryAction["Type"] + +export const BannerAction = Schema.Struct({ + action: RecoveryAction, + label: Schema.String, +}) +export type BannerAction = typeof BannerAction["Type"] + +export const RecoveryBanner = Schema.Struct({ + visible: Schema.Boolean, + tone: BannerTone, + title: Schema.String, + detail: Schema.String, + primary: Schema.optionalKey(BannerAction), + secondary: Schema.optionalKey(BannerAction), + touchTargetPt: Schema.Literals([48]), + anchored: Schema.Literals(["bottom"]), +}) +export type RecoveryBanner = typeof RecoveryBanner["Type"] + +/** Chip shown for a saved capture (the banner itself stays hidden). */ +export const successChip = "Saved to the journal" + +const hidden = (): RecoveryBanner => ({ + visible: false, + tone: "info", + title: "", + detail: "", + touchTargetPt: 48, + anchored: "bottom", +}) + +export const bannerFor = (state: CaptureRecovery): RecoveryBanner => { + switch (state.phase) { + case "drafting": { + if (state.interruptedAt === undefined) return hidden() + return { + visible: true, + tone: "info", + title: "Draft is safe on this device", + detail: "The interruption did not lose your words. Pick up where you left off.", + primary: { action: "resume", label: "Keep recording" }, + touchTargetPt: 48, + anchored: "bottom", + } + } + case "pending": { + if (state.networkLostAt !== undefined) { + return { + visible: true, + tone: "warning", + title: "Waiting for network", + detail: "Will send when you're back online. Your recording is safe on this device.", + touchTargetPt: 48, + anchored: "bottom", + } + } + return { + visible: true, + tone: "info", + title: "Sending…", + detail: "Not saved to the journal yet. A copy stays on this device.", + touchTargetPt: 48, + anchored: "bottom", + } + } + case "failed": { + const reason = state.lastFailure?.reason + if (reason === "unauthorized") { + return { + visible: true, + tone: "warning", + title: "Sign-in needed", + detail: "Your recording is safe on this device.", + primary: { action: "retry", label: "Sign in & send" }, + secondary: { action: "discard", label: "Discard" }, + touchTargetPt: 48, + anchored: "bottom", + } + } + if (reason === "invalid") { + return { + visible: true, + tone: "warning", + title: "Needs a fix before sending", + detail: "Your recording is safe on this device.", + primary: { action: "review", label: "Review" }, + secondary: { action: "discard", label: "Discard" }, + touchTargetPt: 48, + anchored: "bottom", + } + } + return { + visible: true, + tone: "warning", + title: + reason === "server" + ? "Couldn't send — server problem" + : "Couldn't send — network problem", + detail: "Your recording is safe on this device.", + primary: { action: "retry", label: "Try again" }, + secondary: { action: "discard", label: "Discard" }, + touchTargetPt: 48, + anchored: "bottom", + } + } + case "saved": + // Hidden banner still carries success semantics — the surface renders + // the success chip from the same projection. + return { ...hidden(), tone: "success" } + case "discarded": + return { + visible: true, + tone: "neutral", + title: "Discarded — nothing was added to the journal", + detail: "You discarded this recording. Only a receipt remains on this device.", + touchTargetPt: 48, + anchored: "bottom", + } + } +} diff --git a/packages/capture-recovery/src/events.ts b/packages/capture-recovery/src/events.ts new file mode 100644 index 0000000..ceb161a --- /dev/null +++ b/packages/capture-recovery/src/events.ts @@ -0,0 +1,86 @@ +import { Schema } from "effect" +import { ExtractionAttempt } from "@journal/domain" + +import { FailureReason } from "./state.js" + +/** + * Recovery event vocabulary. Interruption, backgrounding, network loss and + * duplicate submission are EVENTS, not states: the machine's persisted state + * is always sufficient to resume, so no recovery-specific phase is needed. + * + * Timestamps come from events only — the reducer is deterministic and never + * reads the clock, so fixtures replay identically anywhere. + */ +export const InterruptionKind = Schema.Literals(["call", "app-switch", "background", "process-death"]) +export type InterruptionKind = typeof InterruptionKind["Type"] + +/** Extraction outcome as returned by the canonical pipeline (never "pending"). */ +export const ExtractionOutcome = Schema.Literals(["structured", "failed"]) +export type ExtractionOutcome = typeof ExtractionOutcome["Type"] + +export const TranscriptChanged = Schema.TaggedStruct("TranscriptChanged", { + at: Schema.Number, + text: Schema.String, +}) + +export const Interrupted = Schema.TaggedStruct("Interrupted", { + at: Schema.Number, + kind: InterruptionKind, +}) + +export const Resumed = Schema.TaggedStruct("Resumed", { + at: Schema.Number, +}) + +export const SubmitRequested = Schema.TaggedStruct("SubmitRequested", { + at: Schema.Number, + submissionId: Schema.NonEmptyString, +}) + +export const RetryRequested = Schema.TaggedStruct("RetryRequested", { + at: Schema.Number, +}) + +export const SubmitAccepted = Schema.TaggedStruct("SubmitAccepted", { + at: Schema.Number, +}) + +export const SubmitRejected = Schema.TaggedStruct("SubmitRejected", { + at: Schema.Number, + attempt: ExtractionAttempt, + reason: FailureReason, +}) + +/** Decoded extraction result for (captureId, attempt) — latest-attempt-only. */ +export const ExtractionResultArrived = Schema.TaggedStruct("ExtractionResultArrived", { + at: Schema.Number, + attempt: ExtractionAttempt, + outcome: ExtractionOutcome, +}) + +export const NetworkLost = Schema.TaggedStruct("NetworkLost", { + at: Schema.Number, +}) + +export const NetworkRestored = Schema.TaggedStruct("NetworkRestored", { + at: Schema.Number, +}) + +export const DiscardRequested = Schema.TaggedStruct("DiscardRequested", { + at: Schema.Number, +}) + +export const CaptureEvent = Schema.Union([ + TranscriptChanged, + Interrupted, + Resumed, + SubmitRequested, + RetryRequested, + SubmitAccepted, + SubmitRejected, + ExtractionResultArrived, + NetworkLost, + NetworkRestored, + DiscardRequested, +]) +export type CaptureEvent = typeof CaptureEvent["Type"] diff --git a/packages/capture-recovery/src/index.ts b/packages/capture-recovery/src/index.ts new file mode 100644 index 0000000..ac524eb --- /dev/null +++ b/packages/capture-recovery/src/index.ts @@ -0,0 +1,32 @@ +export { + CapturePhase, + CaptureRecoveryState, + DiscardReceipt, + FailureReason, + LastFailure, + SubmissionId, +} from "./state.js" +export type { CaptureRecovery } from "./state.js" + +export { + CaptureEvent, + DiscardRequested, + ExtractionOutcome, + ExtractionResultArrived, + InterruptionKind, + Interrupted, + NetworkLost, + NetworkRestored, + Resumed, + RetryRequested, + SubmitAccepted, + SubmitRejected, + SubmitRequested, + TranscriptChanged, +} from "./events.js" + +export { createCapture, reduce } from "./reducer.js" +export { BannerAction, BannerTone, RecoveryBanner, bannerFor, successChip } from "./banner.js" +export { InMemoryCaptureStorage, decodeStoredState } from "./storage.js" +export type { CaptureStorage } from "./storage.js" +export { Fixture, FixtureRunResult, runFixture } from "./replay.js" diff --git a/packages/capture-recovery/src/reducer.ts b/packages/capture-recovery/src/reducer.ts new file mode 100644 index 0000000..5fc9117 --- /dev/null +++ b/packages/capture-recovery/src/reducer.ts @@ -0,0 +1,173 @@ +import type { CaptureEvent } from "./events.js" +import type { CaptureRecovery, DiscardReceipt } from "./state.js" + +/** + * Pure recovery reducer. Total over (state, event): unexpected combinations + * are recorded as bounded anomalies instead of throwing — a recovery path + * must never crash the capture it is recovering. + * + * Deterministic: every timestamp comes from the event; the reducer never + * reads the clock, generates ids, or performs I/O. Fixtures replay + * identically in CI, on device, and in the evaluation harness. + * + * Invariants (each pinned by a test): + * 1. Raw-before-events — only TranscriptChanged and an explicit + * DiscardRequested may touch `rawTranscript`; every other transition + * preserves it verbatim. + * 2. Attempt monotonicity — `attempt` never decreases (canonical envelope). + * 3. Stable submission identity — `submissionId` is assigned at first submit + * and reused across retries; foreign ids while active are anomalies. + * 4. No optimistic truth — `saved` is reachable ONLY through SubmitAccepted + * (idempotent: a repeated accept is a no-op returning the same state). + * 5. Stale-result suppression — envelope results with attempt < current are + * counted and discarded, never merged (contract v0.2 rule b). + * 6. Discard leaves a receipt — the raw text is cleared, the receipt (time, + * length, attempt, submission id) survives so a discarded capture is + * distinguishable from a missing log. + */ + +export const createCapture = (input: { + captureId: string + authorId: string + at: number +}): CaptureRecovery => ({ + captureId: input.captureId, + authorId: input.authorId, + rawTranscript: "", + phase: "drafting", + attempt: 0, + updatedAt: input.at, +}) + +const patch = (state: CaptureRecovery, fields: Partial): CaptureRecovery => { + const next: Record = { ...state } + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) delete next[key] + else next[key] = value + } + return next as unknown as CaptureRecovery +} + +const withAnomaly = (state: CaptureRecovery, event: CaptureEvent, why: string): CaptureRecovery => { + const existing = state.anomalies ?? [] + return patch(state, { + anomalies: [...existing.slice(-9), `${event._tag} ignored: ${why}`], + updatedAt: event.at, + }) +} + +const bumpDuplicates = (state: CaptureRecovery, at: number): CaptureRecovery => + patch(state, { + duplicateSubmissionsSuppressed: (state.duplicateSubmissionsSuppressed ?? 0) + 1, + updatedAt: at, + }) + +const bumpStale = (state: CaptureRecovery, at: number): CaptureRecovery => + patch(state, { + staleResultsSuppressed: (state.staleResultsSuppressed ?? 0) + 1, + updatedAt: at, + }) + +export const reduce = (state: CaptureRecovery, event: CaptureEvent): CaptureRecovery => { + switch (event._tag) { + case "TranscriptChanged": { + if (state.phase !== "drafting") { + return withAnomaly(state, event, `transcript change while ${state.phase}`) + } + return patch(state, { + rawTranscript: event.text, + interruptedAt: undefined, + updatedAt: event.at, + }) + } + case "Interrupted": { + if (state.phase !== "drafting" && state.phase !== "pending") { + return withAnomaly(state, event, `interruption while ${state.phase}`) + } + return patch(state, { interruptedAt: event.at, updatedAt: event.at }) + } + case "Resumed": + return patch(state, { updatedAt: event.at }) + case "SubmitRequested": { + if (state.phase === "drafting") { + if (state.submissionId !== undefined) { + return withAnomaly(state, event, "draft already carries a submissionId") + } + return patch(state, { + phase: "pending", + submissionId: event.submissionId, + extractionStatus: "pending", + updatedAt: event.at, + }) + } + if (state.phase === "pending" || state.phase === "saved") { + if (state.submissionId === event.submissionId) return bumpDuplicates(state, event.at) + return withAnomaly(state, event, `duplicate submit with foreign id while ${state.phase}`) + } + return withAnomaly(state, event, `submit while ${state.phase}`) + } + case "RetryRequested": { + if (state.phase !== "failed") return withAnomaly(state, event, `retry while ${state.phase}`) + return patch(state, { + phase: "pending", + attempt: state.attempt + 1, + extractionStatus: "pending", + updatedAt: event.at, + }) + } + case "SubmitAccepted": { + if (state.phase === "pending") { + return patch(state, { phase: "saved", updatedAt: event.at }) + } + if (state.phase === "saved") return state // idempotent completion + return withAnomaly(state, event, `accept while ${state.phase}`) + } + case "SubmitRejected": { + if (state.phase !== "pending") return withAnomaly(state, event, `rejection while ${state.phase}`) + if (event.attempt < state.attempt) return bumpStale(state, event.at) + if (event.attempt > state.attempt) { + return withAnomaly(state, event, "rejection for a future attempt") + } + return patch(state, { + phase: "failed", + lastFailure: { reason: event.reason, at: event.at }, + updatedAt: event.at, + }) + } + case "ExtractionResultArrived": { + if (state.phase !== "pending" && state.phase !== "saved") { + return withAnomaly(state, event, `result while ${state.phase}`) + } + if (event.attempt < state.attempt) return bumpStale(state, event.at) + if (event.attempt > state.attempt) { + return withAnomaly(state, event, "result for a future attempt") + } + return patch(state, { extractionStatus: event.outcome, updatedAt: event.at }) + } + case "NetworkLost": { + if (state.phase === "saved" || state.phase === "discarded") return state + return patch(state, { networkLostAt: event.at, updatedAt: event.at }) + } + case "NetworkRestored": + return patch(state, { networkLostAt: undefined, updatedAt: event.at }) + case "DiscardRequested": { + if (state.phase !== "drafting" && state.phase !== "failed") { + return withAnomaly(state, event, `discard while ${state.phase}`) + } + const receipt: DiscardReceipt = { + at: event.at, + transcriptLength: state.rawTranscript.length, + attempt: state.attempt, + ...(state.submissionId !== undefined ? { submissionId: state.submissionId } : {}), + } + return patch(state, { + phase: "discarded", + rawTranscript: "", + discardedReceipt: receipt, + updatedAt: event.at, + }) + } + } + // The union above is exhaustive; a decoded CaptureEvent cannot reach here. + return state +} diff --git a/packages/capture-recovery/src/replay.ts b/packages/capture-recovery/src/replay.ts new file mode 100644 index 0000000..bc5004f --- /dev/null +++ b/packages/capture-recovery/src/replay.ts @@ -0,0 +1,183 @@ +import { Schema } from "effect" + +import { bannerFor, RecoveryBanner } from "./banner.js" +import { CaptureEvent } from "./events.js" +import { createCapture, reduce } from "./reducer.js" +import { CapturePhase, CaptureRecoveryState, type CaptureRecovery } from "./state.js" +import { InMemoryCaptureStorage } from "./storage.js" + +/** + * Replayable fixture contract + deterministic runner. + * + * A fixture is a synthetic capture scenario: an initial capture, an ordered + * step list (each step is an event or a `reload` cold start), and the + * expected final projection. Reload steps hydrate state ONLY from what the + * storage adapter persisted — proving that persisted state alone is + * sufficient to resume, which is the truthful-UI guarantee. + */ + +export const FixtureStep = Schema.Struct({ + event: Schema.optionalKey(CaptureEvent), + reload: Schema.optionalKey(Schema.Literals([true])), +}) +export type FixtureStep = typeof FixtureStep["Type"] + +export const FixtureCapture = Schema.Struct({ + captureId: Schema.NonEmptyString, + authorId: Schema.NonEmptyString, + at: Schema.Number, +}) +export type FixtureCapture = typeof FixtureCapture["Type"] + +export const FixtureExpect = Schema.Struct({ + phase: CapturePhase, + tone: Schema.optionalKey(Schema.Literals(["info", "warning", "success", "neutral"])), + title: Schema.optionalKey(Schema.String), + bannerVisible: Schema.optionalKey(Schema.Boolean), + rawTranscript: Schema.optionalKey(Schema.String), + attempt: Schema.optionalKey(Schema.Int), + submissionId: Schema.optionalKey(Schema.String), + duplicateSubmissionsSuppressed: Schema.optionalKey(Schema.Int), + staleResultsSuppressed: Schema.optionalKey(Schema.Int), + hasReceipt: Schema.optionalKey(Schema.Boolean), + rawCleared: Schema.optionalKey(Schema.Boolean), + anomalies: Schema.optionalKey(Schema.Int), + extractionStatus: Schema.optionalKey(Schema.Literals(["pending", "structured", "failed"])), +}) +export type FixtureExpect = typeof FixtureExpect["Type"] + +export const Fixture = Schema.Struct({ + id: Schema.String, + kind: Schema.String, + description: Schema.String, + capture: FixtureCapture, + steps: Schema.Array(FixtureStep), + expect: FixtureExpect, +}) +export type Fixture = typeof Fixture["Type"] + +export const TimelineEntry = Schema.Struct({ + kind: Schema.Literals(["event", "reload", "start"]), + tag: Schema.optionalKey(Schema.String), + phase: CapturePhase, + rawLength: Schema.Int, + attempt: Schema.Int, +}) +export type TimelineEntry = typeof TimelineEntry["Type"] + +export const Check = Schema.Struct({ + name: Schema.String, + expected: Schema.String, + actual: Schema.String, + pass: Schema.Boolean, +}) +export type Check = typeof Check["Type"] + +export const FixtureRunResult = Schema.Struct({ + fixtureId: Schema.String, + passed: Schema.Boolean, + state: CaptureRecoveryState, + banner: RecoveryBanner, + timeline: Schema.Array(TimelineEntry), + checks: Schema.Array(Check), +}) +export type FixtureRunResult = typeof FixtureRunResult["Type"] + +const fmt = (value: unknown): string => (value === undefined ? "(absent)" : JSON.stringify(value)) + +const check = (name: string, expected: unknown, actual: unknown): Check => ({ + name, + expected: fmt(expected), + actual: fmt(actual), + pass: expected === actual, +}) + +/** + * Replay a fixture against the pure machine. Throws only on a malformed + * fixture (a step with neither event nor reload, or a reload with nothing + * persisted); scenario expectations are returned as checks, not thrown. + */ +export const runFixture = (fixture: Fixture): FixtureRunResult => { + let state: CaptureRecovery = createCapture({ ...fixture.capture }) + const storage = new InMemoryCaptureStorage() + storage.save(state) + + const timeline: TimelineEntry[] = [ + { kind: "start", phase: state.phase, rawLength: state.rawTranscript.length, attempt: state.attempt }, + ] + + for (const [index, step] of fixture.steps.entries()) { + if (step.reload === true) { + const reloaded = storage.load(state.captureId) + if (reloaded === undefined) { + throw new Error(`fixture ${fixture.id} step ${index}: reload with nothing persisted`) + } + state = reloaded + timeline.push({ + kind: "reload", + phase: state.phase, + rawLength: state.rawTranscript.length, + attempt: state.attempt, + }) + continue + } + const event = step.event + if (event === undefined) { + throw new Error(`fixture ${fixture.id} step ${index}: step has neither event nor reload`) + } + state = reduce(state, event) + storage.save(state) + timeline.push({ + kind: "event", + tag: event._tag, + phase: state.phase, + rawLength: state.rawTranscript.length, + attempt: state.attempt, + }) + } + + const banner = bannerFor(state) + const checks: Check[] = [check("phase", fixture.expect.phase, state.phase)] + + if (fixture.expect.tone !== undefined) checks.push(check("tone", fixture.expect.tone, banner.tone)) + if (fixture.expect.title !== undefined) checks.push(check("title", fixture.expect.title, banner.title)) + if (fixture.expect.bannerVisible !== undefined) { + checks.push(check("bannerVisible", fixture.expect.bannerVisible, banner.visible)) + } + if (fixture.expect.rawTranscript !== undefined) { + checks.push(check("rawTranscript", fixture.expect.rawTranscript, state.rawTranscript)) + } + if (fixture.expect.attempt !== undefined) checks.push(check("attempt", fixture.expect.attempt, state.attempt)) + if (fixture.expect.submissionId !== undefined) { + checks.push(check("submissionId", fixture.expect.submissionId, state.submissionId)) + } + if (fixture.expect.duplicateSubmissionsSuppressed !== undefined) { + checks.push( + check( + "duplicateSubmissionsSuppressed", + fixture.expect.duplicateSubmissionsSuppressed, + state.duplicateSubmissionsSuppressed ?? 0, + ), + ) + } + if (fixture.expect.staleResultsSuppressed !== undefined) { + checks.push( + check("staleResultsSuppressed", fixture.expect.staleResultsSuppressed, state.staleResultsSuppressed ?? 0), + ) + } + if (fixture.expect.hasReceipt !== undefined) { + checks.push(check("hasReceipt", fixture.expect.hasReceipt, state.discardedReceipt !== undefined)) + } + if (fixture.expect.rawCleared !== undefined) { + checks.push(check("rawCleared", fixture.expect.rawCleared, state.rawTranscript === "")) + } + if (fixture.expect.anomalies !== undefined) { + checks.push(check("anomalies", fixture.expect.anomalies, state.anomalies?.length ?? 0)) + } + if (fixture.expect.extractionStatus !== undefined) { + checks.push(check("extractionStatus", fixture.expect.extractionStatus, state.extractionStatus)) + } + + const passed = checks.every((c) => c.pass) + return { fixtureId: fixture.id, passed, state, banner, timeline, checks } +} diff --git a/packages/capture-recovery/src/state.ts b/packages/capture-recovery/src/state.ts new file mode 100644 index 0000000..773be71 --- /dev/null +++ b/packages/capture-recovery/src/state.ts @@ -0,0 +1,72 @@ +import { Schema } from "effect" +import { CaptureId, ExtractionAttempt, ExtractionStatus } from "@journal/domain" + +/** + * Interrupted-capture recovery state (proposal toward contract v0.3 — bounded + * extension of art_I2TCG08V v0.2; Entry/Event/envelope schemas are NOT + * modified here, only consumed). + * + * Design rules, per the recovery brief: + * - Truthful states only. There is no optimistic "saving" or "sent" phase: + * `pending` means a submission is in flight and NOTHING is known about the + * server outcome until SubmitAccepted/SubmitRejected arrives. + * - Raw-before-events: `rawTranscript` is preserved verbatim on every + * transition. The ONLY mutation that clears it is an explicit caregiver + * DiscardRequested, which leaves a receipt (missing-log vs zero-care rule: + * a discarded capture is a visible receipt, never silent absence). + * - Attribution: `authorId` is carried on the state and survives recovery. + * - Publication/audience stays OUT of this machine: phases describe delivery + * of the capture, never who may see it. Entry.visibility (draft|published) + * remains the only per-entry publication state (contract v0.2). + */ +export const CapturePhase = Schema.Literals(["drafting", "pending", "saved", "failed", "discarded"]) +export type CapturePhase = typeof CapturePhase["Type"] + +export const FailureReason = Schema.Literals(["network", "server", "unauthorized", "invalid"]) +export type FailureReason = typeof FailureReason["Type"] + +export const SubmissionId = Schema.NonEmptyString +export type SubmissionId = typeof SubmissionId["Type"] + +export const DiscardReceipt = Schema.Struct({ + at: Schema.Number, + transcriptLength: Schema.Int, + attempt: ExtractionAttempt, + submissionId: Schema.optionalKey(SubmissionId), +}) +export type DiscardReceipt = typeof DiscardReceipt["Type"] + +export const LastFailure = Schema.Struct({ + reason: FailureReason, + at: Schema.Number, +}) +export type LastFailure = typeof LastFailure["Type"] + +export const CaptureRecoveryState = Schema.Struct({ + /** Same identifier space as the canonical extraction envelope. */ + captureId: CaptureId, + /** Known caregiver attribution — preserved across interruptions. */ + authorId: Schema.NonEmptyString, + /** Raw dictated text, latest revision from the speech layer, verbatim. */ + rawTranscript: Schema.String, + phase: CapturePhase, + /** Stable idempotency key: assigned at first submit, reused on retries. */ + submissionId: Schema.optionalKey(SubmissionId), + /** Extraction attempt counter (canonical envelope), monotonic per capture. */ + attempt: ExtractionAttempt, + /** Mirror of Entry.extractionStatus for pending captures. */ + extractionStatus: Schema.optionalKey(ExtractionStatus), + lastFailure: Schema.optionalKey(LastFailure), + /** Connectivity observation — never used to fabricate a server outcome. */ + networkLostAt: Schema.optionalKey(Schema.Number), + /** Set when an interruption (call / app switch / background / death) lands. */ + interruptedAt: Schema.optionalKey(Schema.Number), + duplicateSubmissionsSuppressed: Schema.optionalKey(Schema.Int), + staleResultsSuppressed: Schema.optionalKey(Schema.Int), + /** Bounded anomaly log for unexpected event/phase combinations. */ + anomalies: Schema.optionalKey(Schema.Array(Schema.NonEmptyString)), + /** Present only after explicit discard — the receipt replaces the raw text. */ + discardedReceipt: Schema.optionalKey(DiscardReceipt), + updatedAt: Schema.Number, +}) +export type CaptureRecovery = typeof CaptureRecoveryState["Type"] diff --git a/packages/capture-recovery/src/storage.ts b/packages/capture-recovery/src/storage.ts new file mode 100644 index 0000000..aaa8300 --- /dev/null +++ b/packages/capture-recovery/src/storage.ts @@ -0,0 +1,51 @@ +import { Schema } from "effect" +import { CaptureRecoveryState, type CaptureRecovery } from "./state.js" + +/** + * Persistence contract for the recovery layer. The reducer is pure; the + * CALLER must persist after every transition (save-on-transition), so that a + * process death at any instant loses nothing. `load` powering a cold start is + * the recovery path exercised by the reload fixtures. + * + * `listUnresolved` is the background queue (drafting/pending/failed) that a + * reconnect sweep reconciles by submissionId. Discarded captures are NOT + * unresolved — they are receipts, listed separately to keep the + * missing-log-vs-zero-care distinction visible. + */ +export interface CaptureStorage { + save(state: CaptureRecovery): void | Promise + load(captureId: string): CaptureRecovery | undefined + listUnresolved(): readonly CaptureRecovery[] + listReceipts(): readonly CaptureRecovery[] +} + +const UNRESOLVED: ReadonlySet = new Set(["drafting", "pending", "failed"]) + +export class InMemoryCaptureStorage implements CaptureStorage { + private readonly states = new Map() + + save(state: CaptureRecovery): void { + this.states.set(state.captureId, state) + } + + load(captureId: string): CaptureRecovery | undefined { + return this.states.get(captureId) + } + + listUnresolved(): readonly CaptureRecovery[] { + return [...this.states.values()].filter((s) => UNRESOLVED.has(s.phase)) + } + + listReceipts(): readonly CaptureRecovery[] { + return [...this.states.values()].filter((s) => s.phase === "discarded") + } +} + +/** + * Fail-closed read for durable adapters: storage bytes are decoded through + * the schema before use, so a corrupt or foreign record can never enter the + * machine. AsyncStorage/SQLite adapters (slot 22's integration) should call + * this on every read. + */ +export const decodeStoredState = (raw: unknown): CaptureRecovery => + Schema.decodeUnknownSync(CaptureRecoveryState)(raw) diff --git a/packages/capture-recovery/test/recovery.test.ts b/packages/capture-recovery/test/recovery.test.ts new file mode 100644 index 0000000..fd8eeea --- /dev/null +++ b/packages/capture-recovery/test/recovery.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { Schema } from "effect" + +import { + CaptureEvent, + CaptureRecoveryState, + Fixture, + InMemoryCaptureStorage, + bannerFor, + createCapture, + decodeStoredState, + reduce, + runFixture, + successChip, + type CapturePhase, + type CaptureRecovery, + type FixtureRunResult, +} from "../src/index.js" + +const fixturesDir = join(import.meta.dir, "..", "fixtures") + +const loadRuns = (): FixtureRunResult[] => + readdirSync(fixturesDir) + .filter((name) => name.endsWith(".json")) + .sort() + .map((name) => { + const raw: unknown = JSON.parse(readFileSync(join(fixturesDir, name), "utf8")) + const fixture = Schema.decodeUnknownSync(Fixture)(raw) + return runFixture(fixture) + }) + +const runs = loadRuns() + +const runById = (id: string): FixtureRunResult => { + const run = runs.find((r) => r.fixtureId === id) + if (run === undefined) throw new Error(`missing fixture run: ${id}`) + return run +} + +describe("recovery fixture replay", () => { + test("every fixture replays to its expected final state", () => { + for (const run of runs) { + const failed = run.checks.filter((c) => !c.pass) + expect(failed).toEqual([]) + expect(run.passed).toBe(true) + } + }) + + test("covers the four brief scenarios plus failure/retry, stale result, and discard receipt", () => { + expect(runs.length).toBe(7) + const ids = new Set(runs.map((r) => r.fixtureId)) + expect(ids.has("interruption-mid-draft")).toBe(true) + expect(ids.has("backgrounding-process-death")).toBe(true) + expect(ids.has("network-loss-pending")).toBe(true) + expect(ids.has("failed-retry-success")).toBe(true) + expect(ids.has("duplicate-submission")).toBe(true) + expect(ids.has("stale-result")).toBe(true) + expect(ids.has("discard-receipt")).toBe(true) + }) + + test("interruption: the reload rehydrates the persisted draft verbatim", () => { + const run = runById("interruption-mid-draft") + const reloads = run.timeline.filter((t) => t.kind === "reload") + expect(reloads.length).toBe(1) + expect(reloads[0]?.phase).toBe("drafting") + expect(reloads[0]?.rawLength).toBe("Ava had a huge meltdown after preschool".length) + expect(run.state.rawTranscript).toBe( + "Ava had a huge meltdown after preschool, calmed by her plush rabbit.", + ) + }) + + test("backgrounding + process death: raw text and attribution survive the cold start", () => { + const run = runById("backgrounding-process-death") + const reloads = run.timeline.filter((t) => t.kind === "reload") + expect(reloads[0]?.rawLength).toBe("Ava ate all of her pasta at dinner".length) + expect(run.state.authorId).toBe("caregiver-1") + expect(run.state.phase).toBe("saved") + }) + + test("network loss during pending never fabricates a failure or a save", () => { + const run = runById("network-loss-pending") + // timeline: start, SubmitRequested, NetworkLost, NetworkRestored, SubmitAccepted + expect(run.timeline[2]?.tag).toBe("NetworkLost") + expect(run.timeline[2]?.phase).toBe("pending") + expect(run.timeline[3]?.tag).toBe("NetworkRestored") + expect(run.timeline[3]?.phase).toBe("pending") + expect(run.state.phase).toBe("saved") + }) + + test("failed then retried: same submissionId, attempt bumped, latest result applied", () => { + const run = runById("failed-retry-success") + expect(run.state.submissionId).toBe("sub-recover-004") + expect(run.state.attempt).toBe(1) + expect(run.state.extractionStatus).toBe("structured") + }) + + test("duplicate submission: three submits, two suppressed, one saved", () => { + const run = runById("duplicate-submission") + expect(run.state.duplicateSubmissionsSuppressed).toBe(2) + expect(run.state.phase).toBe("saved") + }) + + test("stale extraction result (superseded attempt) is suppressed, never merged", () => { + const run = runById("stale-result") + expect(run.state.staleResultsSuppressed).toBe(1) + expect(run.state.attempt).toBe(1) + expect(run.state.extractionStatus).toBe("structured") + }) + + test("discard leaves a receipt, clears raw, survives reload, resists resurrection", () => { + const run = runById("discard-receipt") + expect(run.state.phase).toBe("discarded") + expect(run.state.rawTranscript).toBe("") + expect(run.state.discardedReceipt?.transcriptLength).toBe( + "Ava bit her friend at pickup, we talked about it".length, + ) + const reloads = run.timeline.filter((t) => t.kind === "reload") + expect(reloads[0]?.phase).toBe("discarded") + expect(run.state.anomalies?.length).toBe(1) + }) +}) + +describe("reducer invariants", () => { + const base: CaptureRecovery = createCapture({ + captureId: "cap-invariant", + authorId: "caregiver-1", + at: 1_000, + }) + + const stream: readonly { event: CaptureEvent; expectPhase: CapturePhase }[] = [ + { event: { _tag: "TranscriptChanged", at: 2_000, text: "she napped 45 minutes" }, expectPhase: "drafting" }, + { event: { _tag: "Interrupted", at: 3_000, kind: "call" }, expectPhase: "drafting" }, + { event: { _tag: "Resumed", at: 4_000 }, expectPhase: "drafting" }, + { event: { _tag: "NetworkLost", at: 5_000 }, expectPhase: "drafting" }, + { event: { _tag: "NetworkRestored", at: 6_000 }, expectPhase: "drafting" }, + { event: { _tag: "SubmitRequested", at: 7_000, submissionId: "sub-invariant" }, expectPhase: "pending" }, + { event: { _tag: "SubmitRejected", at: 8_000, attempt: 0, reason: "network" }, expectPhase: "failed" }, + { event: { _tag: "RetryRequested", at: 9_000 }, expectPhase: "pending" }, + { event: { _tag: "SubmitAccepted", at: 10_000 }, expectPhase: "saved" }, + { + event: { _tag: "ExtractionResultArrived", at: 11_000, attempt: 1, outcome: "structured" }, + expectPhase: "saved", + }, + { event: { _tag: "SubmitAccepted", at: 12_000 }, expectPhase: "saved" }, + ] + + test("raw transcript is touched only by TranscriptChanged (and DiscardRequested)", () => { + let state = base + for (const step of stream) { + const before = state.rawTranscript + state = reduce(state, step.event) + expect(state.phase).toBe(step.expectPhase) + if (step.event._tag !== "TranscriptChanged" && step.event._tag !== "DiscardRequested") { + expect(state.rawTranscript).toBe(before) + } + } + }) + + test("attempt is monotonic and submissionId is stable across the whole stream", () => { + let state = base + let attempt = 0 + let submissionId: string | undefined = undefined + for (const step of stream) { + state = reduce(state, step.event) + expect(state.attempt).toBeGreaterThanOrEqual(attempt) + attempt = state.attempt + if (state.submissionId !== undefined) { + if (submissionId === undefined) submissionId = state.submissionId + expect(state.submissionId).toBe(submissionId) + } + } + }) + + test("idempotent completion: a repeated SubmitAccepted returns the SAME state object", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-idem" }) + state = reduce(state, { _tag: "SubmitAccepted", at: 4_000 }) + const again = reduce(state, { _tag: "SubmitAccepted", at: 5_000 }) + expect(again).toBe(state) + }) + + test("saved is unreachable without SubmitAccepted (no optimistic truth)", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-nopt" }) + state = reduce(state, { _tag: "NetworkLost", at: 4_000 }) + state = reduce(state, { _tag: "NetworkRestored", at: 5_000 }) + state = reduce(state, { _tag: "Resumed", at: 6_000 }) + state = reduce(state, { _tag: "ExtractionResultArrived", at: 7_000, attempt: 0, outcome: "structured" }) + expect(state.phase).toBe("pending") + }) + + test("foreign submissionId while active is an anomaly, never a takeover", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-mine" }) + state = reduce(state, { _tag: "SubmitRequested", at: 4_000, submissionId: "sub-foreign" }) + expect(state.phase).toBe("pending") + expect(state.submissionId).toBe("sub-mine") + expect(state.anomalies?.length).toBe(1) + }) +}) + +describe("truthful UI projection", () => { + const base = createCapture({ captureId: "cap-ui", authorId: "caregiver-1", at: 1_000 }) + + test("pending never claims saved; failed copy names the problem and keeps the raw safe", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-ui" }) + const sending = bannerFor(state) + expect(sending.title).toBe("Sending…") + expect(sending.detail).toContain("Not saved to the journal yet") + + state = reduce(state, { _tag: "NetworkLost", at: 4_000 }) + const waiting = bannerFor(state) + expect(waiting.title).toBe("Waiting for network") + + state = reduce(state, { _tag: "SubmitRejected", at: 5_000, attempt: 0, reason: "network" }) + const failed = bannerFor(state) + expect(failed.title).toBe("Couldn't send — network problem") + expect(failed.detail).toContain("safe on this device") + expect(failed.primary?.action).toBe("retry") + expect(failed.secondary?.action).toBe("discard") + }) + + test("one-handed encoding: bottom-anchored, 48pt targets, at most two actions", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-1h" }) + state = reduce(state, { _tag: "SubmitRejected", at: 4_000, attempt: 0, reason: "server" }) + const banner = bannerFor(state) + expect(banner.anchored).toBe("bottom") + expect(banner.touchTargetPt).toBe(48) + expect(banner.primary).toBeDefined() + expect(banner.secondary).toBeDefined() + }) + + test("saved hides the recovery banner and surfaces the success chip", () => { + let state = base + state = reduce(state, { _tag: "TranscriptChanged", at: 2_000, text: "hello" }) + state = reduce(state, { _tag: "SubmitRequested", at: 3_000, submissionId: "sub-chip" }) + state = reduce(state, { _tag: "SubmitAccepted", at: 4_000 }) + const banner = bannerFor(state) + expect(banner.visible).toBe(false) + expect(successChip).toBe("Saved to the journal") + }) +}) + +describe("storage contract", () => { + test("state survives an encode -> wire -> decode roundtrip unchanged", () => { + const run = runById("interruption-mid-draft") + const encoded = Schema.encodeSync(CaptureRecoveryState)(run.state) + const wire: unknown = JSON.parse(JSON.stringify(encoded)) + const decoded = decodeStoredState(wire) + expect(decoded).toEqual(run.state) + }) + + test("unresolved captures and discard receipts are listed separately", () => { + const storage = new InMemoryCaptureStorage() + const live = createCapture({ captureId: "cap-live", authorId: "caregiver-1", at: 1_000 }) + let dead = createCapture({ captureId: "cap-dead", authorId: "caregiver-1", at: 2_000 }) + dead = reduce(dead, { _tag: "TranscriptChanged", at: 3_000, text: "private note" }) + dead = reduce(dead, { _tag: "DiscardRequested", at: 4_000 }) + storage.save(live) + storage.save(dead) + expect(storage.listUnresolved().map((s) => s.captureId)).toEqual(["cap-live"]) + expect(storage.listReceipts().map((s) => s.captureId)).toEqual(["cap-dead"]) + }) + + test("fail-closed read: a corrupt stored record cannot enter the machine", () => { + expect(() => decodeStoredState({ captureId: "cap-x", phase: "not-a-phase" })).toThrow() + }) +}) diff --git a/packages/capture-recovery/tsconfig.json b/packages/capture-recovery/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/capture-recovery/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/capture-recovery/tsconfig.test.json b/packages/capture-recovery/tsconfig.test.json new file mode 100644 index 0000000..e4021e2 --- /dev/null +++ b/packages/capture-recovery/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["bun"] + }, + "include": ["src", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f9a503..75b69ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,22 @@ importers: specifier: ~6.0.3 version: 6.0.3 + packages/capture-recovery: + dependencies: + '@journal/domain': + specifier: workspace:* + version: link:../domain + effect: + specifier: 4.0.0-rc.115 + version: 4.0.0-rc.115 + devDependencies: + '@types/bun': + specifier: ^1.3.14 + version: 1.4.2 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + packages/domain: dependencies: convex: