import Foundation import HapiClient import HapiProtocol import Observation /// Per-open-chat transport wiring (M2f) — the iOS counterpart of the /// Android `ChatViewModel`'s lifecycle half: /// /// - opens this session's ``MessageWindowController`` through the hub's /// registry (snapshot hydration + `activate()`), then kicks a tail sync; /// - owns the **session-scope** SSE subscription while the chat is on screen /// (dual-subscription model: `HubSession` keeps the global pipe for the /// list) and routes its events: /// - message-stream family → the window controller's ingest hooks, /// - `session-updated`/lifecycle + `machine-updated` + `toast` → the /// shared `SyncEventRouter` (detail patching included), /// - `session-removed` for this session → clears the window, /// - a `gap` handshake verdict → full REST resync + detail refetch + /// the window's catch-up tail sync (`ensureAfterCurrent`); /// - suspends/resumes with the scene phase (forwarded by `HubSession`) and /// on `stop()` hands its SSE resume cursor back to the hub session, so a /// reopened chat resumes instead of replaying cold. /// /// **Ordering.** The window must observe SSE events in arrival order (the /// Android port funnels them through a single-consumer Channel for this). /// Here the same guarantee falls out of structure: `SSEClient` yields into /// one `AsyncStream`, exactly one task consumes it, and every event is /// `await`ed to completion — including the hop into the window controller's /// actor — before the next one is pulled. One producer, one consumer, one /// awaited hop per event: no interleaving is possible. @MainActor @Observable final class ChatSession { let sessionId: String /// Session-pipe notice, delayed and stable across transport retry phases. let reconnectNotice = SSEReconnectNotice() /// From this pipe's latest handshake; needed for `POST /api/visibility` /// (M3b) — new on every reconnect. private(set) var subscriptionId: String? private(set) var isRemoved = false /// Set once `start()` opened the window; the chat model observes its /// state stream and calls its `fetchOlder`/`syncTail`. private(set) var windowController: MessageWindowController? /// Fired (on the main actor) after SSE events that can update stores so /// the chat model can re-run its pipeline over the freshly patched data. @ObservationIgnored var onStoreActivity: (@MainActor () -> Void)? @ObservationIgnored var onSessionRemoved: (@MainActor () -> Void)? private let baseURL: URL private let authManager: AuthManager private let windows: MessageWindowControllers private let sessionStore: SessionListStore @ObservationIgnored private let router: SyncEventRouter @ObservationIgnored private let initialCursor: String? @ObservationIgnored private let registerActive: @MainActor (ChatSession) -> Void @ObservationIgnored private let unregisterActive: @MainActor (ChatSession) -> Void @ObservationIgnored private let saveCursor: @MainActor (String?) -> Void /// Fired on every handshake with the fresh subscription id (visibility /// reporting). @ObservationIgnored private let onHandshake: @MainActor (String?) -> Void @ObservationIgnored private var sse: SSEClient? @ObservationIgnored private var consumeTask: Task? @ObservationIgnored private var started = false @ObservationIgnored private var stopped = false init( sessionId: String, baseURL: URL, authManager: AuthManager, windows: MessageWindowControllers, sessionStore: SessionListStore, machineStore: MachineStore, initialCursor: String?, registerActive: @escaping @MainActor (ChatSession) -> Void, unregisterActive: @escaping @MainActor (ChatSession) -> Void = { _ in }, saveCursor: @escaping @MainActor (String?) -> Void, onHandshake: @escaping @MainActor (String?) -> Void = { _ in } ) { self.sessionId = sessionId self.baseURL = baseURL self.authManager = authManager self.windows = windows self.sessionStore = sessionStore self.router = SyncEventRouter(sessions: sessionStore, machines: machineStore) self.initialCursor = initialCursor self.registerActive = registerActive self.unregisterActive = unregisterActive self.saveCursor = saveCursor self.onHandshake = onHandshake } // MARK: - Lifecycle /// Open the window, then subscribe: every routed message event finds the /// controller already in place (the Android wiring order). Idempotent. func start(preservingHistory: Bool = false) async { guard !started, !stopped else { return } started = true registerActive(self) let controller = await windows.open(sessionId: sessionId) guard !stopped else { return } if preservingHistory { await controller.setViewMode(.history) } else { await controller.activate() } // A stop() can land while the opens above were suspended; do not // bring the SSE up for a dead chat. guard !stopped else { return } windowController = controller startSessionSSE() // Explicit catch-up on entry (the snapshot may be stale); the SSE // handshake's own gap handling covers everything missed after this. if !preservingHistory { Task { await controller.syncTail() } } } /// Tears the session pipe down; the engine-side resume cursor is saved /// so a reopened chat for this session resumes where it left off. /// Idempotent — both the view's disappear and a hub shutdown call it. func stop() { guard !stopped else { return } stopped = true // Clears the hub session's open-chat marker (push suppression) — // identity-guarded on the other side against register/stop races. unregisterActive(self) onStoreActivity = nil onSessionRemoved = nil consumeTask?.cancel() consumeTask = nil reconnectNotice.update(.idle) // Nobody observes the detail once the chat closes (mirror of the // Android `sessionStore.releaseDetail`). sessionStore.releaseDetail(sessionId) if let sse { self.sse = nil let save = saveCursor Task { let cursor = await sse.lastEventId await sse.stop() save(cursor) } } } /// Scene-phase forwarding (see `HubSession.enterForeground`): resume the /// parked retry loop, with the 45 s staleness distrust for a socket that /// survived suspension. func enterForeground() { guard started, !stopped, let sse else { return } Task { await sse.resume() } } func enterBackground() { guard let sse else { return } Task { await sse.suspend() } } // MARK: - Session-scope SSE private func startSessionSSE() { let auth = authManager let configuration = SSEClientConfiguration( baseUrl: baseURL, tokenProvider: { // Transient failures park the SSE loop in backoff; the // terminal (re-pair required) case is owned by the global // pipe in `HubSession`, which tears this session down too. try? await auth.validToken() }, scope: .session(sessionId) ) let client = SSEClient(configuration: configuration, pathObserver: NWPathObserver()) sse = client let cursor = initialCursor consumeTask = Task { [weak self] in // Seed before start (the client ignores seeds after the run // loop exists); both calls run sequentially on the client actor. if let cursor { await client.seedCursor(cursor) } let stream = await client.start() for await event in stream { guard let self, !Task.isCancelled else { return } // Sequential handling — see the ordering note on the type. await self.handle(event) } } } private func handle(_ event: SSEClientEvent) async { switch event { case .stateChanged(let state): reconnectNotice.update(state) // Transport phases do not change the stores or transcript. return case .handshake(let resume, let subscriptionId): self.subscriptionId = subscriptionId onHandshake(subscriptionId) // `.ok` = the replay that follows covers the gap. Anything else // (including the verdict-less first connect) cannot prove // continuity for this filter set. if resume != .ok { recoverFromGap() } case .event(let syncEvent): await route(syncEvent) } onStoreActivity?() } private func route(_ event: SyncEvent) async { switch event { case .messageReceived, .messagesConsumed, .messageCancelled, .messagesInvalidated, .scheduledMatured: // Message-stream family → the open window (the controller // re-checks the session id defensively). if let controller = windowController { await controller.onMessageEvent(event) } case .sessionRemoved(_, let removedId): router.route(event, scope: .session(sessionId)) if removedId == sessionId { isRemoved = true // Web `clearMessageWindow` on session-removed. await windows.clear(sessionId: sessionId) onSessionRemoved?() } default: // `session-updated` (detail patch / full session), lifecycle, // `machine-updated`, `toast` — the shared store fan-out. router.route(event, scope: .session(sessionId)) } } /// `resume: gap` on the session pipe (Android `requestFullResync` with a /// session key): list + cached details + machines over REST, make sure /// THIS detail exists even if it was never cached, and catch the window /// up past any in-flight sync (web `resyncMessages`). Since M3a the /// catch-up runs through `reconcileQueuedState()` — it begins with the /// same draining tail sync and then verifies optimistic queued rows /// against the hub (web queued-state reconciliation after a gap). private func recoverFromGap() { router.requestFullResync() if sessionStore.detail(for: sessionId) == nil { let store = sessionStore let id = sessionId Task { try? await store.loadSessionDetail(id) } } if let controller = windowController { Task { do { try await controller.reconcileQueuedState() } catch { // The reconcile's REST round trip failed — the tail sync // inside it already ran/flagged; nothing more to do. } } } } }