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
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user