import Foundation import NucleicProtocol /// One staged transfer item on disk: the file to stream plus its manifest descriptor. struct StagedTransferItem: Sendable { let kind: TransferItemKind let url: URL let descriptor: TransferItemDescriptor } /// The **Carbon Copy** of a session — the source-side replica the Covalence mesh streams to a /// destination host (mesh P5). This is the Carbon (data) layer's unit of replication: it gathers /// the session's data (transcript, optional native transcript, and — after the destination reports /// `haveSHAs` — the git bundle), hashes each item, and builds the `TransferOffer`. Everything is /// staged under a per-transfer temp directory so a chunk stream can be re-read on resume and /// cleaned up on completion. (The *transport* that carries it is Covalence; the copied *data* is /// Carbon.) public final class SessionCarbonCopy: @unchecked Sendable { public let transferID: String public let offer: TransferOffer let stagingDir: URL /// Items known at offer time (transcript, optional native). The bundle is added post-accept. /// Mutated only within the owning `SessionTransferCoordinator` actor. private var stagedItems: [TransferItemKind: StagedTransferItem] private let worktree: Worktree private let project: Project private let worktrees: any WorktreeManaging private init( transferID: String, offer: TransferOffer, stagingDir: URL, stagedItems: [TransferItemKind: StagedTransferItem], worktree: Worktree, project: Project, worktrees: any WorktreeManaging ) { self.transferID = transferID self.offer = offer self.stagingDir = stagingDir self.stagedItems = stagedItems self.worktree = worktree self.project = project self.worktrees = worktrees } /// Prepare the transcript + native items and the offer. The git bundle is deferred to /// `buildBundle(haveSHAs:)` because it depends on what the destination already has. /// /// - Parameters: /// - transcriptURL: our JSONL transcript file for the session. /// - nativeTranscriptURL: the native agent transcript, if present (best-effort memory carry). /// When nil, `record.backendSessionID` is forced nil so the destination starts fresh. /// - stagingRoot: a scratch dir (e.g. `…/Nucleic/transfers`); a `` subdir is made. public static func prepare( transferID: String, session: Session, project: Project, projectDescriptor: ProjectDescriptor, worktree: Worktree, transcriptURL: URL, nativeTranscriptURL: URL?, branchTipSHA: String, transcriptHeaderVersion: Int, stagingRoot: URL, worktrees: any WorktreeManaging ) throws -> SessionCarbonCopy { let stagingDir = stagingRoot.appendingPathComponent(transferID, isDirectory: true) try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) var items: [TransferItemKind: StagedTransferItem] = [:] var known: [TransferItemDescriptor] = [] // Transcript (always present, the UI truth). let transcriptStaged = stagingDir.appendingPathComponent("transcript.jsonl") try copyItem(from: transcriptURL, to: transcriptStaged) let tItem = try makeItem(kind: .transcript, url: transcriptStaged) items[.transcript] = tItem known.append(tItem.descriptor) // Native transcript — best effort. Only claim memory-carry (a non-nil backendSessionID in // the record) if we actually have the native file to send. var carryBackendSessionID: String? = nil if let nativeTranscriptURL, FileManager.default.fileExists(atPath: nativeTranscriptURL.path), let backendSessionID = session.backendSessionID { let nativeStaged = stagingDir.appendingPathComponent("native.jsonl") try copyItem(from: nativeTranscriptURL, to: nativeStaged) let nItem = try makeItem(kind: .nativeTranscript, url: nativeStaged) items[.nativeTranscript] = nItem known.append(nItem.descriptor) carryBackendSessionID = backendSessionID } let record = SessionTransferRecord( sessionID: session.id, backend: session.backend, backendSessionID: carryBackendSessionID, title: session.title, branch: worktree.branch, baseSHA: worktree.baseSHA, model: session.model, effort: session.effort, lastSeq: session.lastSeq, createdAt: session.createdAt, covalenceOriginDeviceID: session.covalenceOriginDeviceID) let offer = TransferOffer( transferID: transferID, record: record, project: projectDescriptor, transcriptHeaderVersion: transcriptHeaderVersion, availableBaseSHAs: [worktree.baseSHA, branchTipSHA].filter { !$0.isEmpty }, knownItems: known) return SessionCarbonCopy( transferID: transferID, offer: offer, stagingDir: stagingDir, stagedItems: items, worktree: worktree, project: project, worktrees: worktrees) } // (initializer above) /// Build the git bundle against the destination's `haveSHAs`, stage it, and return its /// descriptor — added to the item set so it can be streamed. Idempotent (re-buildable on resume). @discardableResult public func buildBundle(haveSHAs: [String]) async throws -> TransferItemDescriptor { let bundleURL = stagingDir.appendingPathComponent("session.bundle") try await worktrees.createTransferBundle(worktree, in: project, haveSHAs: haveSHAs, to: bundleURL) let item = try Self.makeItem(kind: .bundle, url: bundleURL) stagedItems[.bundle] = item return item.descriptor } /// The staged item for a kind (for streaming), if prepared. func item(_ kind: TransferItemKind) -> StagedTransferItem? { stagedItems[kind] } /// The order items are streamed in: bundle first (largest, verified first), then transcript, /// then native. var streamOrder: [TransferItemKind] { [.bundle, .transcript, .nativeTranscript].filter { stagedItems[$0] != nil } } /// Remove the staging directory once the transfer completes (or is abandoned). public func cleanup() { try? FileManager.default.removeItem(at: stagingDir) } // MARK: - Helpers private static func copyItem(from source: URL, to dest: URL) throws { try? FileManager.default.removeItem(at: dest) try FileManager.default.copyItem(at: source, to: dest) } private static func makeItem(kind: TransferItemKind, url: URL) throws -> StagedTransferItem { let attrs = try FileManager.default.attributesOfItem(atPath: url.path) let byteCount = (attrs[.size] as? Int) ?? 0 let sha = try TransferHash.sha256Hex(ofFileAt: url) let chunkCount = max(1, Int((Double(byteCount) / Double(TransferChunk.maxDataBytes)).rounded(.up))) return StagedTransferItem( kind: kind, url: url, descriptor: TransferItemDescriptor( kind: kind, byteCount: byteCount, sha256: sha, chunkCount: chunkCount)) } }