Files
nucleic/Sources/NucleicCore/Transcript.swift
T

438 lines
20 KiB
Swift

import Foundation
/// Line 0 of every transcript file (BACKEND_PROTOCOL §6).
public struct SessionHeader: Sendable, Codable, Equatable {
/// The current transcript format version stamped on new headers.
public static let currentVersion = 1
/// The highest header version this build can read. A session-transfer import rejects a
/// transcript whose `version` exceeds this (the `versionMismatch` reject) — this is the
/// first consumer of transcript versioning (mesh P5). Bump alongside `currentVersion` when
/// a format change stays readable; leave it behind only for a genuinely breaking change.
public static let maxSupported = 1
public let version: Int
public let sessionID: SessionID
public let backend: BackendID
public var backendSessionID: String?
public let worktree: WorktreePath
public let model: String?
public let nativeTranscriptPath: String?
public let createdAt: Date
/// Monotonic count of reverts (undo) this transcript has undergone. A revert resets the seq
/// counter (`TranscriptWriter.truncate`), so seqs only identify content *within* one epoch;
/// stamping the epoch on line 0 makes the file self-describing — a mirror synced via a full
/// fetch (which ships this header verbatim) holds the epoch on disk, a re-serving holder
/// propagates it, and a session transfer carries it to the new owner. `nil` = written before
/// the field existed (equivalent to 0: never reverted, or provenance unknown). Optional and
/// version-neutral (`version` stays 1) so old builds read new files and vice versa.
public var revertEpoch: UInt64?
public init(
version: Int = 1,
sessionID: SessionID,
backend: BackendID,
backendSessionID: String? = nil,
worktree: WorktreePath,
model: String? = nil,
nativeTranscriptPath: String? = nil,
createdAt: Date,
revertEpoch: UInt64? = nil
) {
self.version = version
self.sessionID = sessionID
self.backend = backend
self.backendSessionID = backendSessionID
self.worktree = worktree
self.model = model
self.nativeTranscriptPath = nativeTranscriptPath
self.createdAt = createdAt
self.revertEpoch = revertEpoch
}
/// Encode/decode the header for the wire (full-transcript mesh sync carries it as opaque
/// `Data` in `TranscriptFetchComplete.headerJSON`, since the wire layer can't name this type).
/// The exact bytes needn't match line 0 — the receiver re-encodes via `TranscriptWriter` — so
/// a plain iso8601 JSON coder suffices.
public func encodedForWire() throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(self)
}
public static func decodeFromWire(_ data: Data) throws -> SessionHeader {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(SessionHeader.self, from: data)
}
}
/// Append-only JSONL transcript — the UI's source of truth (locked decision).
/// The canonical, session-wide `seq` is (re)assigned here at append time, the
/// single serialization point (RUNTIME §2.1): adapters' provisional seq is only
/// an ordering hint, and synthetic events (setup logs, approval echoes) need
/// numbering too.
public actor TranscriptWriter {
private let url: URL
private let fileHandle: FileHandle
private let encoder: JSONEncoder
private var renderIndexAppender: TranscriptRenderIndexAppender?
public private(set) var lastSeq: UInt64
/// Run this actor on its own dispatch queue instead of the shared Swift cooperative
/// pool. Every method here does BLOCKING file I/O (`write(contentsOf:)`, and
/// `synchronize()` — an fsync that can stall for seconds on a struggling disk); on the
/// width-limited cooperative pool (one thread per core, non-expandable) a few sessions
/// writing to a slow volume parked enough pool threads to starve the entire concurrency
/// runtime — decode loops, watchdogs, everything (the app-wide "frozen until stop"
/// class, hang-report sweep 2026-07-28). On a private queue a blocked write costs one
/// expendable GCD thread. Semantics are unchanged: the queue is serial, so actor
/// isolation and operation atomicity hold exactly as before.
///
/// Via ``SerialQueueExecutor`` rather than `DispatchSerialQueue`, which exists only in the
/// Darwin Dispatch overlay — corelibs-libdispatch doesn't vend it, so the direct spelling
/// doesn't compile on Windows or Linux. Same private serial queue either way.
private nonisolated let ioExecutor = SerialQueueExecutor(
label: "com.nucleic.transcript-io", qos: .utility)
public nonisolated var unownedExecutor: UnownedSerialExecutor {
ioExecutor.asUnownedSerialExecutor()
}
public init(url: URL, header: SessionHeader) throws {
self.url = url
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
renderIndexAppender = nil
FileManager.default.createFile(atPath: url.path, contents: nil)
fileHandle = try FileHandle(forWritingTo: url)
lastSeq = 0
var headerLine = try encoder.encode(header)
headerLine.append(0x0A)
try fileHandle.write(contentsOf: headerLine)
renderIndexAppender = try? TranscriptRenderIndexAppender(
newTranscriptAt: url, header: header, canonicalHeaderBytes: headerLine.count)
if renderIndexAppender == nil { try? TranscriptRenderIndex.invalidate(for: url) }
}
/// Reopen an existing transcript for appending (resume after relaunch). Does not
/// rewrite the header; continues numbering from `lastSeq`.
public init(
appendingTo url: URL, lastSeq: UInt64, initialHistory: [AgentEvent]? = nil
) throws {
self.url = url
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
renderIndexAppender = nil
fileHandle = try FileHandle(forWritingTo: url)
try fileHandle.seekToEnd()
self.lastSeq = lastSeq
renderIndexAppender = TranscriptRenderIndexAppender(
appendingTo: url, expectedLastSequence: lastSeq,
initialHistory: initialHistory)
}
/// Appends and returns the event re-stamped with its canonical seq.
@discardableResult
public func append(_ event: AgentEvent) throws -> AgentEvent {
lastSeq += 1
let canonical = event.withSeq(lastSeq)
var line = try encoder.encode(canonical)
line.append(0x0A)
try fileHandle.write(contentsOf: line)
appendRenderIndex(canonical, canonicalByteLength: line.count)
return canonical
}
/// Append an event **verbatim** — keeping its existing canonical `seq` instead of
/// re-stamping. Used when mirroring another device's transcript (full-transcript mesh sync):
/// the source already assigned canonical seqs, and re-stamping would clobber them and break
/// the receiver's dedup/gap logic. `lastSeq` tracks the written seq so a reopened mirror
/// resumes correctly. This is the second deliberate verbatim-seq path alongside `truncate`;
/// steady-state owned writing still goes through `append` (RUNTIME §2.1).
public func appendVerbatim(_ event: AgentEvent) throws {
var line = try encoder.encode(event)
line.append(0x0A)
try fileHandle.write(contentsOf: line)
appendRenderIndex(event, canonicalByteLength: line.count)
lastSeq = event.seq
}
/// Rewrite the transcript so it ends at `events` (the session header on line 0 is
/// preserved verbatim, or replaced by `header` when provided — the revert path stamps the
/// bumped `revertEpoch` there in the same rewrite), discarding everything after — used when
/// the user reverts the conversation to an earlier point. Events keep their existing
/// canonical seqs; the append cursor is repositioned to the new end and `lastSeq` reset, so
/// the next turn continues numbering cleanly. Append-only is the steady state (RUNTIME
/// §2.1); this is the one deliberate, user-initiated rewrite.
public func truncate(to events: [AgentEvent], header: SessionHeader? = nil) throws {
let headerLine: Data
if let header {
headerLine = try encoder.encode(header)
} else {
let existing = try Data(contentsOf: url)
headerLine =
existing.firstIndex(of: 0x0A).map { existing.subdata(in: existing.startIndex..<$0) }
?? existing
}
var out = headerLine
out.append(0x0A)
for event in events {
out.append(try encoder.encode(event))
out.append(0x0A)
}
try fileHandle.seek(toOffset: 0)
try fileHandle.write(contentsOf: out)
try fileHandle.truncate(atOffset: UInt64(out.count))
try fileHandle.synchronize()
lastSeq = events.last?.seq ?? 0
// Revert replaces the epoch/sequence namespace and every canonical offset after line 0.
// Install a fresh derived index from the already-decoded retained events.
try? renderIndexAppender?.close()
renderIndexAppender = nil
do {
try TranscriptRenderIndex.rebuild(transcriptURL: url, events: events)
renderIndexAppender = TranscriptRenderIndexAppender(
appendingTo: url, expectedLastSequence: lastSeq,
initialHistory: events)
} catch {
try? TranscriptRenderIndex.invalidate(for: url)
}
}
/// Durability point — called at turn boundaries and approval persistence
/// (RUNTIME open-Q #2: batched fsync).
public func sync() throws {
try fileHandle.synchronize()
do {
try renderIndexAppender?.sync()
} catch {
disableRenderIndex()
}
}
public func close() throws {
try? renderIndexAppender?.close()
renderIndexAppender = nil
try fileHandle.close()
}
private func appendRenderIndex(_ event: AgentEvent, canonicalByteLength: Int) {
guard let renderIndexAppender else { return }
do {
try renderIndexAppender.append(event, canonicalByteLength: canonicalByteLength)
} catch {
disableRenderIndex()
}
}
private func disableRenderIndex() {
try? renderIndexAppender?.close()
renderIndexAppender = nil
try? TranscriptRenderIndex.invalidate(for: url)
}
}
/// Rewrites the header (line 0) of an existing transcript file in place, preserving every event
/// line after it. Used by a session-transfer import to point the header at the destination's
/// recomputed worktree / native-transcript paths (mesh P5), and to drop `backendSessionID` when
/// memory-carry falls back to fresh context. The event bytes are copied verbatim so the file's
/// content — apart from line 0 — is byte-identical to what was received.
public enum TranscriptHeaderRewriter {
/// Replace line 0 of the file at `url` with `header` (re-encoded with the canonical
/// `TranscriptWriter` config so hashes stay reproducible), keeping all following lines.
public static func rewrite(at url: URL, to header: SessionHeader) throws {
let existing = try Data(contentsOf: url)
// Everything after the first newline is the event body — preserved verbatim.
let body: Data = existing.firstIndex(of: 0x0A)
.map { existing.subdata(in: existing.index(after: $0)..<existing.endIndex) } ?? Data()
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
encoder.dateEncodingStrategy = .iso8601
var out = try encoder.encode(header)
out.append(0x0A)
out.append(body)
try out.write(to: url, options: .atomic)
// Header length and identity participate in sidecar validation. Removing this derived file
// is cheap and prevents an equal-size rewrite from accidentally preserving stale metadata.
try? TranscriptRenderIndex.invalidate(for: url)
}
/// Build the destination header from a received one: keep the identity fields (version,
/// sessionID, backend, model, createdAt, revertEpoch — the new owner must *continue* the
/// revert counter, not restart it, or every existing mirror would read as diverged), point
/// `worktree`/`nativeTranscriptPath` at the destination's recomputed paths, and set
/// `backendSessionID` (nil ⇒ fresh-context fallback).
public static func destinationHeader(
from source: SessionHeader, worktree: WorktreePath,
nativeTranscriptPath: String?, backendSessionID: String?
) -> SessionHeader {
SessionHeader(
version: source.version,
sessionID: source.sessionID,
backend: source.backend,
backendSessionID: backendSessionID,
worktree: worktree,
model: source.model,
nativeTranscriptPath: nativeTranscriptPath,
createdAt: source.createdAt,
revertEpoch: source.revertEpoch)
}
}
/// Reads a transcript back for resume/history paging. No CLI involved — the
/// transcript is canonical (RUNTIME §6).
public struct TranscriptReader: Sendable {
/// One tail-first paging result. Events inside a batch remain chronological; successive calls
/// move toward older history and their events should be prepended to the already-visible tail.
public struct ReverseBatch: Sendable, Equatable {
public let events: [AgentEvent]
public let nextByteOffset: Int?
public init(events: [AgentEvent], nextByteOffset: Int?) {
self.events = events
self.nextByteOffset = nextByteOffset
}
}
public let url: URL
public init(url: URL) {
self.url = url
}
/// Decode just line 0 — for callers that need the header (e.g. the revert path stamping
/// `revertEpoch`, the fetch server attaching it) without paying to decode every event.
public func readHeader() throws -> SessionHeader {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var line = Data()
while let chunk = try handle.read(upToCount: Self.readChunkSize), !chunk.isEmpty {
if let newline = chunk.firstIndex(of: 0x0A) {
line.append(chunk[..<newline])
break
}
line.append(chunk)
if Task.isCancelled { throw CancellationError() }
}
return try decoder.decode(SessionHeader.self, from: line)
}
public func read() throws -> (header: SessionHeader, events: [AgentEvent]) {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Stream JSONL in bounded chunks instead of materializing the file, its line-slice array,
// and every decoded event at once. The returned event array is the controller's canonical
// history and is unavoidable today; eliminating the other two full-file representations
// keeps several concurrent long-session hydrations from multiplying peak memory.
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var buffer = Data()
var header: SessionHeader?
var events: [AgentEvent] = []
var decodedLineCount = 0
func decodeLine(_ bytes: Data.SubSequence) throws {
guard !bytes.isEmpty else { return }
let line: Data.SubSequence
if bytes.last == 0x0D { line = bytes.dropLast() } else { line = bytes }
guard !line.isEmpty else { return }
if header == nil {
header = try decoder.decode(SessionHeader.self, from: Data(line))
} else {
events.append(try decoder.decode(AgentEvent.self, from: Data(line)))
}
decodedLineCount += 1
if decodedLineCount.isMultiple(of: 256), Task.isCancelled {
throw CancellationError()
}
}
while let chunk = try handle.read(upToCount: Self.readChunkSize), !chunk.isEmpty {
buffer.append(chunk)
var lineStart = buffer.startIndex
while lineStart < buffer.endIndex,
let newline = buffer[lineStart...].firstIndex(of: 0x0A)
{
try decodeLine(buffer[lineStart..<newline])
lineStart = buffer.index(after: newline)
}
if lineStart > buffer.startIndex { buffer.removeSubrange(buffer.startIndex..<lineStart) }
}
if !buffer.isEmpty { try decodeLine(buffer[buffer.startIndex..<buffer.endIndex]) }
guard let header else {
throw CocoaError(.fileReadCorruptFile, userInfo: [NSFilePathErrorKey: url.path])
}
return (header, events)
}
/// Decode one bounded page from the end of the JSONL file. Unlike `read()`, this makes the
/// newest useful history available without visiting or decoding every older line first. The
/// file is mapped read-only, so creating each cursor page does not copy the transcript into
/// heap memory; `beforeByteOffset` resumes immediately before the prior page.
///
/// `maximumEncodedBytes` is a soft bound because one canonical JSON event cannot be split. A
/// single oversized event therefore occupies its own page, while ordinary pages stay within
/// both limits. Work is deliberately synchronous and bounded so callers can run one page per
/// detached task and publish/yield between pages.
public func readReverseBatch(
beforeByteOffset: Int? = nil,
maximumEvents: Int = 24,
maximumEncodedBytes: Int = 128 * 1024
) throws -> ReverseBatch {
let eventLimit = max(1, maximumEvents)
let byteLimit = max(1, maximumEncodedBytes)
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let start = data.startIndex
var upper = beforeByteOffset.map {
data.index(start, offsetBy: min(max(0, $0), data.count))
} ?? data.endIndex
// A canonical writer ends each line with LF. Ignore trailing empty lines without making
// the first page an empty page.
while upper > start, data[data.index(before: upper)] == 0x0A {
upper = data.index(before: upper)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
var newestFirst: [AgentEvent] = []
newestFirst.reserveCapacity(eventLimit)
var encodedBytes = 0
var exhausted = false
while upper > start {
if Task.isCancelled { throw CancellationError() }
guard let newline = data[start..<upper].lastIndex(of: 0x0A) else {
// The only newline-free prefix is line 0 (the SessionHeader), never an event.
exhausted = true
break
}
let lower = data.index(after: newline)
var line = data[lower..<upper]
if line.last == 0x0D { line = line.dropLast() }
upper = newline
guard !line.isEmpty else { continue }
newestFirst.append(try decoder.decode(AgentEvent.self, from: Data(line)))
encodedBytes += line.count
if newestFirst.count >= eventLimit || encodedBytes >= byteLimit { break }
}
if upper <= start { exhausted = true }
let next = exhausted ? nil : data.distance(from: start, to: upper)
return ReverseBatch(events: Array(newestFirst.reversed()), nextByteOffset: next)
}
private static let readChunkSize = 256 * 1024
}