Session Cache Implementation
Nucleic-Session: B2346642-3353-4BD5-9DE2-A8B77BD66C98 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
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.
|
||||||
|
private 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let encoder = JSONEncoder()
|
||||||
|
private static let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
// 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? decoder.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? encoder.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))
|
||||||
|
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? encoder.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? decoder.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user