Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/SessionCache.swift
T

120 lines
6.2 KiB
Swift

import Foundation
import NucleicProtocol
/// An on-device cache of the phone's recent chat history — the session list and the transcripts of
/// the sessions the user has actually opened — so the app can show a read-only history while it's
/// disconnected from every Mac host (UX_IOS offline). It is a *projection cache*, never canonical
/// state: a live host's data always supersedes it, and it's written straight from the same wire
/// types the sync stream delivers (`SessionSummary` / `AgentEvent`), so nothing is invented.
///
/// Stored as JSON files under Application Support (durable across launches, unlike Caches, which the
/// OS may purge under storage pressure):
///
/// NucleicSessionCache/summaries.json — the merged session list, recency-capped + pruned
/// NucleicSessionCache/transcripts/<id>.json — one opened session's event tail
///
/// File I/O is synchronous, but the write/read paths hop onto a utility `Task.detached` so nothing
/// touches disk on the main actor; the wire types are `Sendable`, so the arrays cross the boundary
/// cleanly. Per-session transcript files keep an append-heavy live session from rewriting the whole
/// history on every event, and cap the tail so a long run can't grow unbounded.
enum SessionCache {
/// Keep the most-recently-updated N sessions' summaries; older ones (and their transcript
/// files) are pruned on every save so the cache stays bounded.
private static let sessionLimit = 50
/// Keep the newest N events per session — the tail is what a returning reader wants, and it
/// bounds a long-running session's file. Internal (not private) so the background transcript
/// prefetch can bound a cold session's pull to the same window this cache would keep anyway.
static let eventLimit = 1500
// MARK: - Paths
private static var root: URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
else { return nil }
return base.appendingPathComponent("NucleicSessionCache", isDirectory: true)
}
private static var summariesURL: URL? { root?.appendingPathComponent("summaries.json") }
private static var transcriptsDir: URL? {
root?.appendingPathComponent("transcripts", isDirectory: true)
}
private static func transcriptURL(_ id: SessionID) -> URL? {
transcriptsDir?.appendingPathComponent(transcriptFileName(id))
}
private static func transcriptFileName(_ id: SessionID) -> String {
// Session ids are UUID strings today, but sanitize defensively so an id can never escape
// the cache directory or collide with the summaries file.
let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
return String(id.rawValue.map { allowed.contains($0) ? $0 : "_" }) + ".json"
}
// MARK: - Summaries
/// Persist the recent session list (recency-capped) and prune transcript files whose session is
/// no longer cached. Off the main actor.
static func saveSummaries(_ summaries: [WireSessionSummary]) async {
await Task.detached(priority: .utility) { writeSummaries(summaries) }.value
}
/// Load the cached session list — called once at launch to seed the list before any host
/// connects. Small (≤ `sessionLimit`), so a synchronous read is fine.
static func loadSummaries() -> [WireSessionSummary] {
guard let url = summariesURL, let data = try? Data(contentsOf: url),
let list = try? JSONDecoder().decode([WireSessionSummary].self, from: data)
else { return [] }
return list
}
private static func writeSummaries(_ summaries: [WireSessionSummary]) {
guard let root, let url = summariesURL, let dir = transcriptsDir else { return }
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let kept = Array(summaries.sorted { $0.updatedAt > $1.updatedAt }.prefix(sessionLimit))
if let data = try? JSONEncoder().encode(kept) { try? data.write(to: url, options: .atomic) }
// Drop transcript files for sessions that fell out of the recent set.
let keep = Set(kept.map { transcriptFileName($0.sessionID) })
if let files = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: nil) {
for file in files where !keep.contains(file.lastPathComponent) {
try? FileManager.default.removeItem(at: file)
}
}
}
// MARK: - Transcripts
/// Persist one session's transcript (newest `eventLimit` events by seq). Off the main actor.
static func saveEvents(_ events: [AgentEvent], for id: SessionID) async {
await Task.detached(priority: .utility) { writeEvents(events, id) }.value
}
/// Load a session's cached transcript — used to seed the open transcript instantly, before the
/// host's snapshot/backfill arrives (and merges on top by seq). Off the main actor.
static func loadEvents(_ id: SessionID) async -> [AgentEvent] {
await Task.detached(priority: .utility) { readEvents(id) }.value
}
private static func writeEvents(_ events: [AgentEvent], _ id: SessionID) {
guard !events.isEmpty, let dir = transcriptsDir, let url = transcriptURL(id) else { return }
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let sorted = events.sorted { $0.seq < $1.seq }
let tail = sorted.count > eventLimit ? Array(sorted.suffix(eventLimit)) : sorted
if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) }
}
private static func readEvents(_ id: SessionID) -> [AgentEvent] {
guard let url = transcriptURL(id), let data = try? Data(contentsOf: url),
let events = try? JSONDecoder().decode([AgentEvent].self, from: data)
else { return [] }
return events
}
// MARK: - Clear
/// Drop the whole cache — on full unpair, when there's no longer any Mac whose history this
/// device should hold.
static func clear() {
guard let root else { return }
try? FileManager.default.removeItem(at: root)
}
}