Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Stores/DiskCache.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

115 lines
4.5 KiB
Swift

import Foundation
/// Generic debounced JSON snapshot: one value ↔ one file. Backs the stores'
/// cold-start cache (no database, per the plan) — load synchronously at
/// construction, ``scheduleWrite(_:)`` on every state change, and the
/// debounce collapses SSE bursts into one atomic write.
///
/// Mirrors the Android reference port (`JsonSnapshotStore`): atomicity comes
/// from `Data.write(options: .atomic)` (write-to-temp + rename, Foundation's
/// equivalent of androidx `AtomicFile`), and a torn/corrupt snapshot degrades
/// to `nil` on ``load()`` — the stores then start empty and a REST refetch
/// repopulates.
///
/// `@MainActor` by design: the owning stores are main-actor `@Observable`
/// classes, so the pending-value bookkeeping needs no locking; only the
/// actual file write hops off the main actor.
@MainActor
public final class DiskCache<Value: Codable> {
/// Snapshot location: `<directory>/<filename>`.
public let fileURL: URL
private let debounce: Duration
private var pending: Value?
/// Bumped by every ``scheduleWrite(_:)``; a flush clears `pending` only
/// when no newer value was scheduled while its write was in flight
/// (value-type stand-in for the reference's identity comparison).
private var generation = 0
private var writeTask: Task<Void, Never>?
/// The debounce defaults to 500 ms (the reference's
/// `DEFAULT_DEBOUNCE_MS`); tests inject shorter windows.
public init(
directory: URL,
filename: String,
debounce: Duration = .milliseconds(500)
) {
self.fileURL = directory.appendingPathComponent(filename, isDirectory: false)
self.debounce = debounce
}
/// Synchronous load for cold start; `nil` when absent or undecodable.
public func load() -> Value? {
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return try? HapiJSON.decoder.decode(Value.self, from: data)
}
/// Records `value` as the latest state and (re)starts the debounce window.
public func scheduleWrite(_ value: Value) {
pending = value
generation += 1
writeTask?.cancel()
writeTask = Task { [debounce] in
do {
try await Task.sleep(for: debounce)
} catch {
return // superseded by a newer scheduleWrite or a flush
}
await self.flushPending()
}
}
/// Writes any pending value immediately (app background / tests).
public func flush() async {
writeTask?.cancel()
writeTask = nil
await flushPending()
}
private func flushPending() async {
guard let value = pending else { return }
let flushedGeneration = generation
let url = fileURL
// Encode on the main actor (small JSON), write off it.
let data = try? HapiJSON.encoder.encode(value)
if let data {
await Task.detached(priority: .utility) {
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: url, options: .atomic)
} catch {
// A failed snapshot write only costs the next cold start.
}
}.value
}
// Cleared only when no newer value arrived during the write — a
// superseding scheduleWrite keeps its value pending for its own
// flush (same guarantee as the reference's identity check).
if generation == flushedGeneration {
pending = nil
}
}
}
/// Where per-hub snapshots live:
/// `<Application Support>/HapiKit/hubs/<encoded-origin>/`.
///
/// The base directory is injectable (tests use a temp dir); the origin is
/// percent-encoded down to alphanumerics so it is filesystem-safe and
/// deterministic per hub.
public enum SnapshotLocations {
public static func directory(forHub hubUrl: String, base: URL? = nil) -> URL {
let root = base
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let encoded = hubUrl.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "hub"
return root
.appendingPathComponent("HapiKit", isDirectory: true)
.appendingPathComponent("hubs", isDirectory: true)
.appendingPathComponent(encoded, isDirectory: true)
}
}