mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
Move tool output into a live sheet with group navigation and full-content copying. Keep lightweight grouped summaries inline, add agent process pages, and preserve transcript reading position.\n\nAdd real transcript and sheet regression coverage with localized summaries.
261 lines
11 KiB
Swift
261 lines
11 KiB
Swift
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<Void, Never>?
|
|
@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.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|