mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
HapiProtocol (pure, mirroring the Android reference port):
- SummaryPatching: sessionSummary.ts derivations (pending requests
cap-5/oldest-first/id-tiebreak, kinds, todo progress), toSessionSummary
projection with the per-flavor agentSessionId resolution (legacy chain
omits piSessionId, replicated), applySessionSummaryPatch with the
deliberate >= version gates (vs the detail path's strict >), the
keep-alive render-irrelevance filter (pendingRequests compare ignores
'since'; metadata compare ignores hapiMcpUrl), max-monotonic updatedAt,
post-patch updatedAt as fallback 'since', and the deprecated legacy
detail-required gate kept for rule pinning.
- SessionSorting: exact list comparator (globalPinned > pinned > active >
pendingRequestsCount desc among active > updatedAt desc; stable).
- SessionMetadata: per-flavor session-id fields (claude/codex/gemini/
opencode/grok/agy/cursor/kimi/copilot/pi).
HapiClient stores (@MainActor @Observable, per hub):
- DiskCache: 500 ms debounced atomic JSON snapshots + SnapshotLocations.
- SessionListStore: sorted summaries + detail cache; full-session upsert
preserving hub-computed scheduled fields; strict-> detail vs >= summary
patch paths; keep-alive identity preservation (listRevision); empty-{}
and unparseable payloads take the REST fallback; 16 ms coalesced
refresh; optimistic pin (roll-forward) and archive (restore).
- MachineStore: the exact machine-updated decision tree.
- LastSeenStore: monotonic unread watermarks + per-scope baseline.
- SyncEventRouter: SyncEvent fan-out to the stores, global-scope message
events refresh the list, gap handshake triggers the full resync.
App:
- HubSession owns the stores + router (replaces the TODO(M2) routing);
background flushes snapshots.
- Features/Sessions: SessionListView + SessionListModel (status dot with
thinking pulse, title cascade, flavor·machine·worktree meta, relative
age on a minute timeline, pending/todo badges, unread dots, pinned
section, machine filter chips >= 2, pull-to-refresh, empty/loading/
offline states, long-press pin/archive context menu) + an M2f chat
placeholder pushed on row tap.
- HomePlaceholderView -> HomeView hosting the list (hub switcher +
connection dot kept in the toolbar).
Tests transcribe the Android suites: SummaryPatchingTest (18),
SessionSortingTest (6), SessionStoreTest, MachineStoreTest,
LastSeenStoreTest, JsonSnapshotStoreTest, StoreSyncTargetsTest — using
the existing HTTPPerforming recording stub plus a path-routing performer
for concurrent refetches.
102 lines
4.2 KiB
Swift
102 lines
4.2 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Fan-out from an SSE subscription's event stream to the stores — the
|
|
/// session-list half of the event routing, mirroring the global-scope rules
|
|
/// of `web/src/hooks/useSSE.ts` via the Android port
|
|
/// (`SyncEventRouter` + `StoreSyncTargets`):
|
|
///
|
|
/// - session lifecycle events → `SessionListStoring.applySessionEvent`;
|
|
/// - `machine-updated` → `MachineListStoring.applyMachineEvent`;
|
|
/// - global-scope message-stream events refresh the session list where the
|
|
/// web invalidates it (`messages-invalidated`, `messages-consumed`,
|
|
/// `message-cancelled`, `scheduled-matured`, and a `message-received`
|
|
/// carrying `scheduledAt` — they all move the hub-computed
|
|
/// scheduled/queued fields the client cannot derive);
|
|
/// - `toast` → the injected callback;
|
|
/// - a `gap` handshake verdict triggers the full REST resync (session list +
|
|
/// cached details + machines); `ok` means the hub replays every missed
|
|
/// event right after the handshake, so the REST resync is skipped;
|
|
/// - `heartbeat`/`connection-changed`/unknown types are engine/no-op
|
|
/// territory here.
|
|
///
|
|
/// The app's `HubSession` owns one of these per hub and calls
|
|
/// ``handleHandshake(resume:)`` / ``route(_:scope:)`` from its SSE consume
|
|
/// loop.
|
|
///
|
|
/// WIRING(M2f): the message-window bookkeeping the web also performs here
|
|
/// (`markMessagesConsumed` / optimistic reconciliation / ingest into the open
|
|
/// window) belongs to the message-window store — the session-scope branch of
|
|
/// ``route(_:scope:)`` grows a window target when it lands; the session-list
|
|
/// handling below stays as is.
|
|
@MainActor
|
|
public struct SyncEventRouter {
|
|
private let sessions: any SessionListStoring
|
|
private let machines: any MachineListStoring
|
|
private let onToast: @MainActor (ToastPayload) -> Void
|
|
|
|
public init(
|
|
sessions: any SessionListStoring,
|
|
machines: any MachineListStoring,
|
|
onToast: @escaping @MainActor (ToastPayload) -> Void = { _ in }
|
|
) {
|
|
self.sessions = sessions
|
|
self.machines = machines
|
|
self.onToast = onToast
|
|
}
|
|
|
|
/// `resume == .ok` skips the resync (the replay that follows covers every
|
|
/// missed event); `.gap` — or, defensively, anything else — triggers the
|
|
/// full refetch. (`SSEClient` already maps an absent wire verdict to
|
|
/// `.gap`, per the contract's absence rule.)
|
|
public func handleHandshake(resume: ResumeVerdict?) {
|
|
guard resume != .ok else { return }
|
|
requestFullResync()
|
|
}
|
|
|
|
/// Full REST resync: session list + cached details, then machines.
|
|
/// Failures are swallowed — offline keeps the snapshot state and the
|
|
/// next reconnect retries.
|
|
public func requestFullResync() {
|
|
let sessions = self.sessions
|
|
let machines = self.machines
|
|
Task { @MainActor in
|
|
try? await sessions.fullResync()
|
|
try? await machines.refresh()
|
|
}
|
|
}
|
|
|
|
/// Routes one decoded `SyncEvent` from the subscription identified by
|
|
/// `scope` (the dual-subscription model: the global pipe drives the
|
|
/// list; per-chat `.session` pipes arrive with M2f and their
|
|
/// message-stream events belong to the message window, not the list).
|
|
public func route(_ event: SyncEvent, scope: SSEScope = .global) {
|
|
switch event {
|
|
case .sessionAdded, .sessionUpdated, .sessionRemoved, .sessionEnded:
|
|
sessions.applySessionEvent(event)
|
|
|
|
case .machineUpdated(_, let machineId, let data):
|
|
machines.applyMachineEvent(machineId: machineId, data: data)
|
|
|
|
case .messagesInvalidated, .messagesConsumed, .messageCancelled, .scheduledMatured:
|
|
guard scope == .global else { return }
|
|
sessions.scheduleRefresh()
|
|
|
|
case .messageReceived(_, _, let message):
|
|
guard scope == .global else { return }
|
|
if message.scheduledAt != nil {
|
|
sessions.scheduleRefresh()
|
|
}
|
|
|
|
case .toast(_, let payload):
|
|
onToast(payload)
|
|
|
|
case .heartbeat, .connectionChanged, .unknown:
|
|
// Heartbeats feed the SSE watchdog; the handshake surfaces
|
|
// through `handleHandshake`; unknown types are forward-compat
|
|
// no-ops.
|
|
break
|
|
}
|
|
}
|
|
}
|