Files
workouts/WATCH-SYNC-SCENARIOS.md
rzen 9ea5e54b15 Add phone/watch scenario diagrams companion doc
Sequence and state diagrams for every phone↔watch interaction (splits,
exercise drive, run end, edit locks, cold launch, session lifecycle),
companion to WATCH-SYNC.md's channel reference.

Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
2026-07-09 06:01:28 -04:00

440 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).