Files
nucleic/Sources/NucleicCore/Transfer/SessionCarbonCopy.swift
T

155 lines
7.1 KiB
Swift

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 `<transferID>` 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))
}
}