// // WorkoutSessionCoordinator.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import Foundation import HealthKit import SwiftData /// What the watch should do with its `HKWorkoutSession` given the latest authoritative /// workout set. `finish` carries the completed run whose metrics we save + forward. enum SessionEndDecision: Equatable { case none case discard case finish(WorkoutDocument) } /// Pure decision seam for the watch's workout-session lifecycle — the session-free core the /// `WorkoutSessionCoordinator` delegates to, mirroring the `WatchCacheApplier` pattern so the /// start/end rules are unit-testable without HealthKit or a live cache. /// /// The end rule reconciles against the *authoritative* workout set (the same set the phone /// pushes and the watch prunes to), not against any view's lifetime — a running session ends /// on a genuine non-empty → empty transition of the active set. Comparing against the active /// set captured at the *previous* reconcile is what makes the launch race safe: at startup the /// session can be `running` before the run document has synced, but the previous active set is /// still empty, so we never discard a run we simply haven't heard about yet. /// /// The start rule makes the watch own its session: whenever the authoritative set holds an /// active run and no session is running, one should be started. The phone's /// `startWatchApp(toHandle:)` handoff then becomes a fast-launch optimization rather than the /// only way a session is ever born — a dropped handoff (watch unreachable at start, a handoff /// swallowed by `start`'s idempotency guard while the old run's session was still running), /// a crash/reboot, or a run engaged manually on the wrist all heal at the next reconcile. enum SessionEndPlanner { /// A `.finish` for a session younger than this is demoted to `.discard`: a seconds-old /// session has no meaningful samples, and the demotion is what keeps the two stale-data /// races from polluting Apple Health with junk workouts attributed to the wrong run — /// (a) a transient "old run completed, new run not yet pushed" context arriving right /// after the new run's session started, and (b) a self-start off stale cache at launch, /// corrected seconds later by the fresh context. In both cases the active set refills /// and `shouldStart` brings the session right back. static let minimumFinishAge: TimeInterval = 30 /// Ids of the runs that keep the session alive — not-yet-finished (`notStarted` / /// `inProgress`). A `completed` / `skipped` run has dropped off the active set. static func activeIDs(in workouts: [WorkoutDocument]) -> Set { Set(workouts .filter { let status = WorkoutStatus(rawValue: $0.status) return status == .notStarted || status == .inProgress } .map(\.id)) } /// True when a session should be running but isn't — the watch self-starts one rather /// than depending on the phone's one-shot launch handoff having arrived. static func shouldStart(isRunning: Bool, workouts: [WorkoutDocument]) -> Bool { !isRunning && !activeIDs(in: workouts).isEmpty } /// The run whose routine configures a self-started session: the most recently started /// active one (ties broken by id for determinism). static func runToStart(in workouts: [WorkoutDocument]) -> WorkoutDocument? { let ids = activeIDs(in: workouts) return workouts .filter { ids.contains($0.id) } .max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start } } /// `sessionAge` is seconds since the running session started (nil when unknown — e.g. a /// recovered session, which is by definition old enough to finish normally). static func decide(isRunning: Bool, sessionAge: TimeInterval?, previouslyActiveIDs: Set, workouts: [WorkoutDocument]) -> SessionEndDecision { guard isRunning, activeIDs(in: workouts).isEmpty, !previouslyActiveIDs.isEmpty else { return .none } // A previously-active run that survived as `.completed` is a finish (save + forward // metrics); if none survived, every active run vanished (discarded/deleted) → discard. // Among several completed survivors (parallel runs), take the most recently started — // deterministic, and the one whose lifetime best matches the session's. let finished = previouslyActiveIDs .compactMap { id in workouts.first { $0.id == id } } .filter { WorkoutStatus(rawValue: $0.status) == .completed } .max { a, b in a.start == b.start ? a.id < b.id : a.start < b.start } guard let finished else { return .discard } // Too young to be a real finish — see `minimumFinishAge`. if let sessionAge, sessionAge < minimumFinishAge { return .discard } return .finish(finished) } } /// The single owner of the watch's workout-session lifecycle — both ends of it. Long-lived /// (held by `WatchAppServices`, not a view), so it runs on the WatchConnectivity delegate /// callback — with or without a mounted view, including in the session's own background /// runtime. Termination here is the fix for the leak where ending a run *from the phone* /// while the watch app was backgrounded left the `HKWorkoutSession` running (watchOS then /// re-foregrounded the app on every wrist raise) and skipped the metrics capture entirely. /// Self-starting here is the counterpart fix: a session is started whenever the authoritative /// set holds an active run and none is running, so a dropped phone launch handoff (or a /// crash, or a run engaged manually on the wrist) can't leave a workout sessionless — no HR, /// no Health save, app suspending wrist-down. /// /// Same move as the live-mirror's `repairFromDurable`, one layer down: drive the terminal /// action from the authoritative cache apply rather than a transient UI observer. @MainActor final class WorkoutSessionCoordinator { private let sessionManager: WorkoutSessionManager private let bridge: WatchConnectivityBridge private let context: ModelContext /// The active set captured at the previous reconcile — the baseline the non-empty → empty /// transition is measured against (see `SessionEndPlanner`). private var previouslyActiveIDs: Set = [] /// Guards the async finish so a burst of reconciles can't double-end the session. private var isEnding = false init(sessionManager: WorkoutSessionManager, bridge: WatchConnectivityBridge, context: ModelContext) { self.sessionManager = sessionManager self.bridge = bridge self.context = context } /// Re-evaluate the session against the current authoritative cache. Called after every /// cache mutation (phone push or the watch's own optimistic edit) and once at bootstrap to /// seed the baseline. The watch cache holds only active + recently-pruned runs, so the /// fetch-and-map is bounded and cheap. func reconcile() { guard !isEnding else { return } let workouts = ((try? context.fetch(FetchDescriptor())) ?? []) .map(WorkoutDocument.init(from:)) let decision = SessionEndPlanner.decide( isRunning: sessionManager.isRunning, sessionAge: sessionManager.sessionStartDate.map { -$0.timeIntervalSinceNow }, previouslyActiveIDs: previouslyActiveIDs, workouts: workouts) previouslyActiveIDs = SessionEndPlanner.activeIDs(in: workouts) switch decision { case .none: startIfNeeded(workouts: workouts) case .discard: sessionManager.discard() case .finish(let doc): endAndForward(doc) } } /// Self-start a session when an active run has none (see `SessionEndPlanner.shouldStart`). /// The activity type comes from the run's routine so the saved Health workout is categorized /// the same way a phone-launched one would be; a run whose routine is gone (deleted, or not /// yet synced) falls back to traditional strength training. private func startIfNeeded(workouts: [WorkoutDocument]) { guard SessionEndPlanner.shouldStart(isRunning: sessionManager.isRunning, workouts: workouts), let run = SessionEndPlanner.runToStart(in: workouts) else { return } let routine = run.routineID.flatMap { id in (try? context.fetch(FetchDescriptor())).flatMap { $0.first { $0.id == id } } } let configuration = HKWorkoutConfiguration() configuration.activityType = routine?.activityTypeEnum.hkActivityType ?? .traditionalStrengthTraining configuration.locationType = .indoor sessionManager.start(with: configuration) } /// Finish the session (saving the `HKWorkout` to Health), attach the captured metrics, and /// forward the run back to the phone. Lifted from the old `ActiveWorkoutGateView.endSession` /// so the behavior is unchanged — only its trigger moved off the view. private func endAndForward(_ doc: WorkoutDocument) { isEnding = true Task { defer { isEnding = false } guard var metrics = await sessionManager.finishAndSave() else { return } metrics.totalVolume = WorkoutVolume.total(doc.logs) var updated = doc updated.metrics = metrics updated.updatedAt = Date() bridge.update(workout: updated) // optimistic upsert + forward (queued if offline) } } }