Merge nucleic/brisk-lunar-seal-yhdq into dev
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user