mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +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.
133 lines
4.4 KiB
Swift
133 lines
4.4 KiB
Swift
import Foundation
|
|
import HapiProtocol
|
|
import Observation
|
|
|
|
/// Machine-list store surface (production impl ``MachineStore``; tests fake
|
|
/// it — same interface extraction as the Android port's `MachineListStore`).
|
|
@MainActor
|
|
public protocol MachineListStoring: AnyObject {
|
|
/// API order preserved (the reference never re-sorts machines).
|
|
var machines: [Machine] { get }
|
|
|
|
/// `GET /api/machines`. Throws on failure.
|
|
func refresh() async throws
|
|
|
|
/// Coalesced fire-and-forget ``refresh()``.
|
|
func scheduleRefresh()
|
|
|
|
/// Routes the `machine-updated` SSE event's already-discriminated data.
|
|
func applyMachineEvent(machineId: String, data: MachineUpdatedData?)
|
|
}
|
|
|
|
/// Online machines for one hub. `machine-updated` handling is the exact web
|
|
/// decision tree (`web/src/hooks/useSSE.ts` +
|
|
/// `sse.md#syncevent-union-13-types`), which `MachineUpdatedData`'s decoder
|
|
/// has already discriminated:
|
|
///
|
|
/// 1. full `Machine` → upsert — except `active: false`, which removes;
|
|
/// 2. explicit `null` data (`.removed`) → machine removed;
|
|
/// 3. strict `MachinePatch` with `active: false` → remove; any other patch
|
|
/// carries too little to upsert → refetch (an empty `{}` decodes as an
|
|
/// all-nil patch here, which lands on the same refetch branch the web's
|
|
/// non-empty parse rule produces);
|
|
/// 4. absent (`nil`) / unrecognized data → refetch.
|
|
@MainActor @Observable
|
|
public final class MachineStore: MachineListStoring {
|
|
public private(set) var machines: [Machine]
|
|
|
|
/// Bumps on every real list mutation (see `SessionListStore.listRevision`).
|
|
public private(set) var listRevision = 0
|
|
|
|
@ObservationIgnored private let api: APIClient
|
|
@ObservationIgnored private let snapshot: DiskCache<[Machine]>?
|
|
@ObservationIgnored private let refreshBatch: Duration
|
|
@ObservationIgnored private var refreshQueued = false
|
|
@ObservationIgnored private var refreshGeneration = 0
|
|
@ObservationIgnored private var lastAppliedRefresh = 0
|
|
|
|
public init(
|
|
api: APIClient,
|
|
snapshotDirectory: URL? = nil,
|
|
refreshBatch: Duration = .milliseconds(16)
|
|
) {
|
|
let cache = snapshotDirectory.map {
|
|
DiskCache<[Machine]>(directory: $0, filename: "machines.json")
|
|
}
|
|
self.api = api
|
|
self.snapshot = cache
|
|
self.refreshBatch = refreshBatch
|
|
self.machines = cache?.load() ?? []
|
|
}
|
|
|
|
/// Forces the debounced snapshot to disk (app background / tests).
|
|
public func flushPersistence() async {
|
|
await snapshot?.flush()
|
|
}
|
|
|
|
public func refresh() async throws {
|
|
refreshGeneration += 1
|
|
let generation = refreshGeneration
|
|
let list = try await api.listMachines()
|
|
guard generation > lastAppliedRefresh else { return }
|
|
lastAppliedRefresh = generation
|
|
setMachines(list)
|
|
}
|
|
|
|
public func scheduleRefresh() {
|
|
guard !refreshQueued else { return }
|
|
refreshQueued = true
|
|
Task { [refreshBatch] in
|
|
try? await Task.sleep(for: refreshBatch)
|
|
self.refreshQueued = false
|
|
// Retried by the next event or manual refresh.
|
|
try? await self.refresh()
|
|
}
|
|
}
|
|
|
|
public func applyMachineEvent(machineId: String, data: MachineUpdatedData?) {
|
|
switch data {
|
|
case .machine(let machine):
|
|
upsert(machine)
|
|
case .removed:
|
|
remove(machineId)
|
|
case .patch(let patch):
|
|
if patch.active == false {
|
|
remove(machineId)
|
|
} else {
|
|
scheduleRefresh()
|
|
}
|
|
case .unrecognized, nil:
|
|
scheduleRefresh()
|
|
}
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
/// Web `upsertMachine`: inactive rows are dropped, order preserved.
|
|
private func upsert(_ machine: Machine) {
|
|
var list = machines
|
|
let index = list.firstIndex { $0.id == machine.id }
|
|
if !machine.active {
|
|
guard let index else { return }
|
|
list.remove(at: index)
|
|
} else if let index {
|
|
list[index] = machine
|
|
} else {
|
|
list.append(machine)
|
|
}
|
|
setMachines(list)
|
|
}
|
|
|
|
private func remove(_ machineId: String) {
|
|
let next = machines.filter { $0.id != machineId }
|
|
guard next.count != machines.count else { return }
|
|
setMachines(next)
|
|
}
|
|
|
|
private func setMachines(_ next: [Machine]) {
|
|
machines = next
|
|
listRevision += 1
|
|
snapshot?.scheduleWrite(next)
|
|
}
|
|
}
|