Merge nucleic/brisk-lunar-seal-yhdq into dev

This commit is contained in:
2026-07-17 22:39:03 -07:00
parent 9af5019bf7
commit 4af015c283
3 changed files with 59 additions and 0 deletions
@@ -83,6 +83,10 @@ final class HostConnection {
/// Backfilled history for the open session (the full-transcript fetch). Merged into the /// Backfilled history for the open session (the full-transcript fetch). Merged into the
/// transcript by seq, not appended — these events precede the tail already on screen. /// transcript by seq, not appended — these events precede the tail already on screen.
var openBackfill: (EventBatch) -> Void = { _ in } var openBackfill: (EventBatch) -> Void = { _ in }
/// The open session was reverted/undone on the owner — drop every event past `throughSeq`
/// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so
/// RemoteStore can confirm it's still the open one before mutating.
var openReverted: (SessionID, UInt64) -> Void = { _, _ in }
/// A background transcript prefetch finished — everything it fetched (empty when the /// A background transcript prefetch finished — everything it fetched (empty when the
/// host had nothing / the fetch died with the connection). RemoteStore merges it into /// host had nothing / the fetch died with the connection). RemoteStore merges it into
/// the offline cache and starts the next queued prefetch either way. /// the offline cache and starts the next queued prefetch either way.
@@ -740,6 +744,20 @@ final class HostConnection {
// Collapsed Bash summary lines the owner pushes to peer *Macs* (mesh session sync). // Collapsed Bash summary lines the owner pushes to peer *Macs* (mesh session sync).
// The phone renders its own deterministic command summaries, so it ignores these. // The phone renders its own deterministic command summaries, so it ignores these.
break break
case .transcriptReverted(let reverted):
// The owner reverted/undid this chat — the transcript was truncated to `throughSeq`.
if reverted.sessionID == openSessionID {
// On screen: drop our cursor/dedup state for the dropped seqs (so a warm-resubscribe
// replays from the new tip, not the stale one) and have RemoteStore shrink the live
// transcript + rewrite its cache to match.
seenSeq = seenSeq.filter { $0 <= reverted.throughSeq }
openMaxSeq = reverted.throughSeq == 0 ? nil : reverted.throughSeq
callbacks.openReverted(reverted.sessionID, reverted.throughSeq)
} else {
// Not on screen: nothing live to shrink, but truncate the offline cache so a later
// open doesn't re-seed the dropped tail via the grow-only seed merge.
SessionCache.truncateEvents(for: reverted.sessionID, throughSeq: reverted.throughSeq)
}
case .relayMembership(let membership): case .relayMembership(let membership):
// The host issued/refreshed this device's relay credential (mesh P2). Persist it // The host issued/refreshed this device's relay credential (mesh P2). Persist it
// in place (no reordering — this can arrive from a non-active Mac) so the relay // in place (no reordering — this can arrive from a non-active Mac) so the relay
@@ -1226,6 +1226,22 @@ final class RemoteStore: ObservableObject {
self.openEvents = Self.mergedEvents(self.openEvents, batch.events) self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
self.persistOpenTranscript() self.persistOpenTranscript()
} }
cb.openReverted = { [weak self] sessionID, throughSeq in
guard let self, hostID == self.openSessionHostID, sessionID == self.openSessionID else { return }
// A revert/undo on the owner truncated the transcript. Drain buffered deltas first (they
// belong to the transcript being truncated), then drop every event past the new tip —
// the one path that *shrinks* the on-screen transcript rather than growing it.
self.drainPendingOpenEvents()
self.openEvents = self.openEvents.filter { $0.seq <= throughSeq }
if self.openEvents.isEmpty {
// A full revert cleared the transcript — drop the cache too, else a later
// reopen-from-cache (offline) would resurrect the reverted events via the grow-only
// seed merge (`saveEvents` refuses an empty write, so it can't clear it for us).
SessionCache.removeEvents(for: sessionID)
} else {
self.persistOpenTranscript()
}
}
cb.transcriptPrefetched = { [weak self] sessionID, events in cb.transcriptPrefetched = { [weak self] sessionID, events in
guard let self else { return } guard let self else { return }
self.prefetchInFlight = nil self.prefetchInFlight = nil
@@ -101,6 +101,31 @@ enum SessionCache {
if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) } if let data = try? JSONEncoder().encode(tail) { try? data.write(to: url, options: .atomic) }
} }
/// Drop one session's cached transcript. Used when a revert/undo clears the whole transcript:
/// `saveEvents` refuses an empty write (so it can't overwrite with nothing), but a reopen-from-
/// cache seeds via a grow-only merge, so a stale cache would resurrect the reverted events — this
/// removes the file outright so the reopened transcript starts empty and the live stream refills.
static func removeEvents(for id: SessionID) {
guard let url = transcriptURL(id) else { return }
try? FileManager.default.removeItem(at: url)
}
/// Shrink one session's cached transcript to `throughSeq` — drop every event past it — so a
/// revert/undo on the owner doesn't leave a later reopen-from-cache reseeding the dropped tail
/// via the grow-only seed merge. Removes the file when nothing is left. No-op if uncached or
/// already at/below `throughSeq`.
static func truncateEvents(for id: SessionID, throughSeq: UInt64) {
guard let url = transcriptURL(id) else { return }
let cached = readEvents(id)
guard cached.contains(where: { $0.seq > throughSeq }) else { return }
let kept = cached.filter { $0.seq <= throughSeq }
if kept.isEmpty {
try? FileManager.default.removeItem(at: url)
} else if let data = try? JSONEncoder().encode(kept) {
try? data.write(to: url, options: .atomic)
}
}
private static func readEvents(_ id: SessionID) -> [AgentEvent] { private static func readEvents(_ id: SessionID) -> [AgentEvent] {
guard let url = transcriptURL(id), let data = try? Data(contentsOf: url), guard let url = transcriptURL(id), let data = try? Data(contentsOf: url),
let events = try? JSONDecoder().decode([AgentEvent].self, from: data) let events = try? JSONDecoder().decode([AgentEvent].self, from: data)