Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/MachineStore.swift
T
weishu 67b7fe7304 feat(ios): session list store + UI (A-M2a)
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.
2026-08-17 20:17:02 +08:00

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)
}
}