diff --git a/WATCH-SYNC-SCENARIOS.md b/WATCH-SYNC-SCENARIOS.md new file mode 100644 index 0000000..cfa7280 --- /dev/null +++ b/WATCH-SYNC-SCENARIOS.md @@ -0,0 +1,439 @@ +# Phone ↔ Watch — Scenario Diagrams + +Companion to [`WATCH-SYNC.md`](WATCH-SYNC.md) (the channel reference). This file maps +**every phone↔watch interaction** as a transition/sequence diagram, so the current +protocol is fully legible before we redesign it. + +The governing rule is unchanged: **the iPhone is the sole writer of iCloud Drive.** The +watch keeps a local SwiftData cache fed only by phone pushes, edits optimistically, and +round-trips every durable change through the phone. + +## Legend + +**Participants** (consistent across every diagram): + +| Alias | Type | Source | +|---|---|---| +| Phone UI | SwiftUI views | `WorkoutLogsView`, `WorkoutLogListView`, `ExerciseProgressView`, `ExerciseView`, `SplitDetailView` | +| SyncEngine | phone persistence/orchestrator | `Workouts/Sync/SyncEngine.swift` | +| iCloud | JSON documents (source of truth) | `iCloud.dev.rzen.indie.Workouts` | +| PhoneBridge | WatchConnectivity (phone) | `PhoneConnectivityBridge.swift` | +| WatchBridge | WatchConnectivity (watch) | `WatchConnectivityBridge.swift` | +| Watch cache | watch SwiftData (read-through) | fed only by `WatchCacheApplier` | +| Watch UI | SwiftUI views | `ActiveWorkoutGateView`, `WorkoutLogListView`, `ExerciseProgressView`, `LiveRunCoverView` | +| Watch session | `HKWorkoutSession` holder | `WorkoutSessionManager.swift` | +| watchOS | OS / HealthKit | app launch + session runtime | + +**Transports** (named in every message so the reliability is explicit): + +| Transport | API | Semantics | +|---|---|---| +| `appContext` | `updateApplicationContext` | Phone → Watch only. Latest-**state**-wins single slot; delivered even when the watch is asleep. Every push is the *whole* authoritative state. | +| `sendMessage` | `sendMessage` | Bidirectional, **reachable-only**, immediate, not queued. | +| `transferUserInfo` | `transferUserInfo` | Watch → Phone fallback. Queued, guaranteed, survives app death — but **unordered** relative to `sendMessage`. | +| `startWatchApp` | HealthKit `startWatchApp(toHandle:)` | Phone → watchOS, **not** WatchConnectivity. The only way an iPhone app can foreground its watch app. | + +--- + +## State machines + +### Workout status (shared, derived from logs) + +`WorkoutDocument.recomputeStatusFromLogs()` is the single source of the rule; every +screen that mutates a log calls it. A log is *resolved* when `completed` **or** +`skipped`. + +```mermaid +stateDiagram-v2 + [*] --> notStarted: phone mints run on split start + notStarted --> inProgress: any log started + inProgress --> completed: all logs resolved — end stamped + inProgress --> notStarted: all logs reset to notStarted + completed --> inProgress: a completed log reopened — end cleared + inProgress --> [*]: discarded (tombstone) + notStarted --> [*]: discarded (tombstone) + completed --> [*]: pruned from watch after ~24h +``` + +Only `inProgress` / `notStarted` runs are "active"; a `completed` run still rides in +the push (recent set, ≤25, ~24h) but drops off the watch's active list. + +### Watch HKWorkoutSession lifecycle + +The session is what grants the watch app foreground runtime. **This is where the +current design is brittle** — see the note below the diagram. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> running: watchOS launch → WatchAppDelegate.handle(config) → start() + running --> running: second start() ignored (idempotent) + running --> ended: finishAndSave() — active list emptied, a completed run found + running --> ended: discard() — active list emptied, no completed run + running --> ended: system/error (didChangeTo .ended/.stopped, or didFailWithError) + ended --> idle: clear() +``` + +> **The only app-driven exit from `running` is `ActiveWorkoutGateView`'s +> `onChange(of: activeWorkouts)` firing on a non-empty → empty transition.** That +> transition must be *witnessed by a mounted, foregrounded gate view*. When a run is +> ended from the **phone** while the watch app is backgrounded (kept alive only by the +> session) or was torn down and rebuilt, the `onChange` baseline is already empty, so it +> never fires — the session stays `running` and watchOS keeps re-foregrounding the app. +> See scenarios S4/S6 and the closing section. + +--- + +## Scenarios + +### S1 — Phone starts a split + +Two **independent** effects fire: a durable state push (WatchConnectivity) *and* an +out-of-band HealthKit launch. They race; neither waits on the other. + +```mermaid +sequenceDiagram + participant PUI as Phone UI + participant SE as SyncEngine + participant ICD as iCloud + participant PB as PhoneBridge + participant OS as watchOS + participant WS as Watch session + participant WB as WatchBridge + participant WUI as Watch UI + + PUI->>SE: save(workout — notStarted run) + SE->>SE: upsert cache + context.save + SE-->>ICD: enqueue file write (async) + SE->>PB: onCacheChanged + PB->>WB: appContext (splits + workouts + settings + locks) + PUI->>OS: launchWatchWorkout → startWatchApp(config) + OS->>WS: handle(config) → start() + WS->>WS: HKWorkoutSession.startActivity — foreground runtime granted + WB->>WB: upsert + prune cache + WB->>WUI: @Query → run appears in ActiveWorkoutGateView + opt list still empty at launch + WUI->>WB: requestSync + WB->>PB: sendMessage(requestSync) + PB->>WB: appContext (fresh pushAll) + end +``` + +If other runs are already active, the phone first prompts and may `endKeepingProgress` +them (see S6) before this `start`. + +### S2 — Phone drives / starts an exercise + +The **live phase** (Ready→Work→Rest→Finish) and the **durable set data** travel on +different channels: ephemeral frames over `sendMessage`, persisted state over +`appContext`. + +```mermaid +sequenceDiagram + participant PUI as Phone UI + participant SE as SyncEngine + participant PB as PhoneBridge + participant WB as WatchBridge + participant WUI as Watch UI + + Note over PUI: user advances a phase + PUI->>PB: onLive(frame) → sendLiveProgress + PB->>WB: sendMessage(liveProgress, version bumped) + WB->>WUI: applyIncomingLive → follower cover (unless open/muted) + Note over PUI: user completes a set + PUI->>SE: onChange → save() (recompute status, persist) + SE->>PB: onCacheChanged → pushAll + PB->>WB: appContext (authoritative workout state) + WB->>WB: upsert cache — durable set data lands + Note over PUI: user leaves the flow + PUI->>PB: onLiveEnded → sendLiveEnded + PB->>WB: sendMessage(liveEnded) + WB->>WUI: endIncomingLive → drop follower +``` + +If the watch is unreachable, the `liveProgress`/`liveEnded` frame is **staged** (depth 1, +latest-wins) and re-sent on reconnect; a send that *fails* while reachable is retried +with a short backoff. If the frame is lost anyway, the durable set data still lands via +`appContext`, and the watch's open run screen jumps forward to match it (durable +repair) — so a lost frame degrades to a briefly-stale page, never a stuck one. + +### S3 — Watch drives / starts an exercise + +Symmetric to S2, but the durable write must round-trip through the phone (sole writer), +so it uses the `workoutUpdate` channel with a `transferUserInfo` fallback. + +```mermaid +sequenceDiagram + participant WUI as Watch UI + participant WB as WatchBridge + participant PB as PhoneBridge + participant SE as SyncEngine + participant ICD as iCloud + participant PUI as Phone UI + + Note over WUI: user advances a phase + WUI->>WB: onLive(frame) → sendLiveProgress + WB->>PB: sendMessage(liveProgress, version bumped) + PB->>PUI: LiveRunState.apply → follower cover + Note over WUI: user completes a set + WUI->>WB: onChange → update(workout doc) + WB->>WB: optimistic upsert into watch cache + alt phone reachable + WB->>PB: sendMessage(workoutUpdate) + else unreachable or send failed + WB->>PB: transferUserInfo(workoutUpdate) — queued + end + PB->>SE: ingestFromWatch(doc) + SE->>ICD: write file (if updatedAt strictly newer) + SE->>PB: onCacheChanged → pushAll + PB->>WB: appContext (authoritative echo) + Note over WUI: user leaves the flow + WUI->>WB: onLiveEnded → sendLiveEnded + WB->>PB: sendMessage(liveEnded) + PB->>PUI: LiveRunState.end → drop follower +``` + +Recovery is symmetric to S2: a failed `liveProgress`/`liveEnded` send retries with +backoff, and the phone's follower cover runs the same **durable repair** — when the +`WorkoutDocument` echoed back over `appContext` shows completion beyond what the cover +has followed, it jumps forward — so a lost watch→phone frame is briefly-stale, not stuck. + +### Live-mirror delivery invariants (apply to S2 ‖ S3) + +The ephemeral channel is `sendMessage`-only (reachable-only), and phone↔watch +reachability drops exactly when the driving device's wrist is down. Commit `8cbe078` +hardened it with four invariants — full detail in [`WATCH-SYNC.md` §3](WATCH-SYNC.md): + +- **Retry with backoff** — a send that *fails while reachable* retries (exponential, ≤5×) + rather than waiting for a reachability edge that may never come mid-workout. + (`scheduleLiveRetry`, both bridges.) +- **Anti-rollback arbitration** — receiving a frame that outranks the locally *staged* + outbound one drops the staged frame, so a reconnect re-send can't yank the run + backward; a delivery the staged frame outranks is ignored. (`applyIncomingLive`.) +- **Version + anchor ordering** — the shared per-run `version` isn't collision-free + (after a lost frame both sides can mint the same number), so every staleness check + tie-breaks on the wall-clock `phaseStart` — `LiveProgress.isNewer`, pinned by + `LiveProgressOrderingTests`. +- **Durable repair** — the safety net: if a frame is lost outright, the transition's + durable write still lands (S2 via `appContext`, S3 via the `workoutUpdate` + round-trip), and the open run screen jumps forward to the first unfinished set. + (`repairFromDurable`, both run screens.) + +> The last invariant is the template for the session-end fix below: **reconcile against +> authoritative data; don't depend on a transient signal or a mounted view.** The mirror +> now has that safety net — the session lifecycle (S4/S6/S7) still does not. + +### S4 — Phone ends an exercise (and it completes the run) + +A set/exercise completion is the S2 persist path. The interesting case is the **last** +exercise: the run flips to `completed`, drops off the watch's active list, and *should* +end the watch session — but that end is the fragile part. + +```mermaid +sequenceDiagram + participant PUI as Phone UI + participant SE as SyncEngine + participant PB as PhoneBridge + participant WB as WatchBridge + participant WGate as Watch gate view + participant WS as Watch session + + PUI->>SE: complete last exercise → save() + SE->>SE: recomputeStatusFromLogs → completed (end stamped) + SE->>PB: onCacheChanged → pushAll + PB->>WB: appContext (run now completed — leaves active set) + WB->>WB: upsert + prune → activeWorkouts empties + WB->>WGate: @Query update + alt gate view mounted AND foregrounded to witness the transition + WGate->>WS: onChange (non-empty → empty) → finishAndSave() + WS->>WS: session.end() → save HKWorkout, capture metrics + WGate->>WB: update(workout + metrics) → back to phone (S3 tail) + else watch backgrounded / view rebuilt with already-empty list + Note over WGate,WS: onChange never fires → session stays running → app keeps re-foregrounding (BUG) + end +``` + +### S5 — Watch ends an exercise (and it completes the run) + +The same completion, but originated **on the watch**. This path reliably ends the +session, because the gate view is mounted and foregrounded on the watch exactly while +the user finishes there — the contrast that pins down the S4/S6 bug. + +```mermaid +sequenceDiagram + participant WUI as Watch UI + participant WGate as Watch gate view + participant WS as Watch session + participant WB as WatchBridge + participant PB as PhoneBridge + participant SE as SyncEngine + + WUI->>WB: complete last exercise → update(workout completed) + WB->>WB: optimistic upsert → local run flips to completed + WB->>WGate: @Query → activeWorkouts empties + WGate->>WS: onChange (non-empty → empty) → finishAndSave() + WS->>WS: session.end() + save HKWorkout, capture metrics + WGate->>WB: update(workout + metrics) + WB->>PB: sendMessage / transferUserInfo (workoutUpdate) + PB->>SE: ingestFromWatch → persist → pushAll echo +``` + +### S6 — Phone ends the whole workout early ("Save Workout" / start-new-split) + +`endKeepingProgress()` marks every unfinished log `skipped`, so the recompute resolves +the run to `completed`. Downstream, the watch-session outcome is the **same fragile +path as S4** — this is the exact flow the user hit ("when the split ends, the watch app +keeps coming back up"). + +```mermaid +sequenceDiagram + participant PUI as Phone UI + participant SE as SyncEngine + participant PB as PhoneBridge + participant WB as WatchBridge + participant WGate as Watch gate view + participant WS as Watch session + + Note over PUI: Save Workout button, or picking a new split with runs active + PUI->>SE: endKeepingProgress() → save() + SE->>SE: unfinished logs → skipped; recompute → completed + SE->>PB: onCacheChanged → pushAll + PB->>WB: appContext (run completed — leaves active set) + WB->>WB: upsert + prune → activeWorkouts empties + alt gate view alive to witness transition + WGate->>WS: onChange → finishAndSave() → session.end() + else not witnessed (the common case here) + Note over WGate,WS: session never ends → watch app resurfaces on every wrist raise (BUG) + end +``` + +### S7 — Phone discards a workout + +A soft delete: tombstone written, live file removed. The watch prunes the run and, on +witnessing the emptied list, `discard()`s the session (no completed run exists, so +nothing is saved to Health). + +```mermaid +sequenceDiagram + participant PUI as Phone UI + participant SE as SyncEngine + participant ICD as iCloud + participant PB as PhoneBridge + participant WB as WatchBridge + participant WGate as Watch gate view + participant WS as Watch session + + PUI->>SE: delete(workout) + SE->>ICD: write Stubs/id.json (tombstone) + remove live file + SE->>PB: onCacheChanged → pushAll + PB->>WB: appContext (workout absent from sets) + WB->>WB: prune → activeWorkouts empties + WB->>WGate: @Query update + WGate->>WGate: endSession — no completed run found + WGate->>WS: discard() → session.end(), data thrown away + Note over WGate,WS: same "must witness the transition" fragility as S4/S6 +``` + +### S8 — Edit lock (phone opens an editor) + +An exclusive-edit lock, pushed immediately so it doesn't wait on a cache change. The +watch parks the matching run and blocks re-entry so the two devices never drive the +same run at once. + +```mermaid +sequenceDiagram + participant PUI as Phone editor + participant PB as PhoneBridge + participant WB as WatchBridge + participant WGate as Watch gate view + + PUI->>PB: onAppear → setEditingWorkout(id) / setEditingSplit(id) + PB->>WB: appContext (editing id set) — pushed immediately + WB->>WGate: park run (isLockedForEditing) + pop out if inside it + Note over WGate: row dimmed "Editing on iPhone…", re-entry blocked + PUI->>PB: onDisappear → setEditing…(nil) + PB->>WB: appContext (lock cleared — absent key means not editing) + WB->>WGate: run re-enabled +``` + +`ExerciseView` drives `setEditingWorkout`; `SplitDetailView` drives `setEditingSplit` +(matched against a run's `splitID`). + +### S9 — Watch cold launch / requestSync + +On real hardware `WCSession` activation is async, so the eager read sees an empty +dictionary; the delegate re-applies once activation completes. + +```mermaid +sequenceDiagram + participant WB as WatchBridge + participant PB as PhoneBridge + participant WUI as Watch UI + + Note over WB: activate() at launch + WB->>WB: applyReceivedContext (last context — may be empty on cold launch) + WB->>PB: sendMessage(requestSync) + Note over WB: activationDidComplete (async on device) + WB->>WB: applyReceivedContext again (now valid) + WB->>PB: requestSync again + PB->>WB: appContext (fresh pushAll) + WB->>WUI: @Query populates +``` + +### S10 — Stale watch push vetoed + +The phone gates every inbound `workoutUpdate` so a stale or out-of-order watch copy can +never resurrect a deleted run or roll back a newer edit. + +```mermaid +sequenceDiagram + participant WB as WatchBridge + participant PB as PhoneBridge + participant SE as SyncEngine + + WB->>PB: workoutUpdate (stale copy) + PB->>SE: ingestFromWatch(doc) + alt tombstone or pending-delete exists + SE->>PB: veto (no write) → onCacheChanged + PB->>WB: appContext (authoritative — run absent) + WB->>WB: prune stale run + else updatedAt older than cache + SE->>PB: re-push (no write) → onCacheChanged + PB->>WB: appContext (corrects the watch) + else updatedAt equal (echo) + Note over SE: ignored — duplicate + end +``` + +--- + +## What actually ends the watch session (and why it leaks) + +Ending the run is the one operation whose reliability differs sharply by *where it +originates*: + +| Scenario | Originates | Ends watch session? | Why | +|---|---|---|---| +| S5 — watch completes last exercise | Watch | **Reliable** | Gate view is mounted + foregrounded on the watch, so it witnesses `activeWorkouts` empty and calls `finishAndSave()`. | +| S4 — phone completes last exercise | Phone | **Unreliable** | Depends on the *watch's* gate view being alive to witness the emptied list. | +| S6 — phone "Save Workout" / new split | Phone | **Unreliable** | Same as S4 — the reported bug. | +| S7 — phone discards | Phone | **Unreliable** | Same fragility; `discard()` instead of `finishAndSave()`. | + +**Root cause.** Ending the `HKWorkoutSession` is a *lifecycle/data* concern, but it is +implemented as a *view-level side effect* — `ActiveWorkoutGateView.onChange(of: +activeWorkouts)`. When a run ends from the phone, the watch app is typically backgrounded +(kept alive only by the still-running session) or gets rebuilt by watchOS; in either +case the non-empty → empty transition is never observed by a live view, `finishAndSave()` +/ `discard()` never run, and the session stays `running`. A running session is precisely +what makes watchOS keep re-foregrounding the app — so it "keeps coming back up even if +you close it." + +There is **no** protocol-level "the run is over" signal today: the watch *infers* the +end from the authoritative set shrinking, and the inference is bound to a UI observer. + +Commit `8cbe078` fixed the analogous fragility in the **ephemeral mirror** by adding a +durable safety net (`repairFromDurable` reconciles the open run screen against the +authoritative document instead of trusting the transient frame). The session lifecycle +needs the same move one layer down: drive `session.end()` from the authoritative data +apply, in a long-lived coordinator rather than a mounted view. The concrete plan is in +[`PLAN-watch-session-end.md`](PLAN-watch-session-end.md).