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

135 lines
6.5 KiB
Swift

import Foundation
import NucleicProtocol
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
/// SHA-256 hex of some bytes — the transfer manifest's per-item integrity check.
enum TransferHash {
static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
/// Stream a file through SHA-256 without loading it whole (transcripts/bundles can be large).
static func sha256Hex(ofFileAt url: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
}
/// A reply the source coordinator observes from the destination during a transfer. The
/// production adapter maps `SyncClient.Event` transfer cases (and connection loss) onto these;
/// the loopback test maps the importer's `HostMsg` replies onto them.
public enum TransferReply: Sendable, Equatable {
case accept(TransferAccept)
case reject(TransferReject)
case ready(String)
case committed(String)
case chunkAck(TransferChunkAck)
/// The channel dropped mid-transfer (peer offline / revoked). Carries a human reason.
case disconnected(String)
}
/// The source's channel to the destination: send transfer verbs (as `ClientMsg`, since the
/// source is a client of the destination) and observe replies. In production this wraps the
/// `SyncClient` `PeerClient` holds for the destination Mac; in tests it's an in-memory loopback
/// to a `SessionTransferImporter`.
public protocol TransferChannel: Sendable {
func send(_ msg: ClientMsg) async
func replies() -> AsyncStream<TransferReply>
}
/// What the destination importer needs from its host app to resolve, place, and activate an
/// incoming session — kept behind a protocol so the importer is testable without `AppStore`.
public protocol TransferImportEnvironment: Sendable {
/// Resolve an incoming project descriptor to a local project (by UUID, root commit, or
/// normalized remote). Nil ⇒ the destination doesn't have the project (`projectNotFound`).
func resolveProject(_ descriptor: ProjectDescriptor) async -> Project?
/// The directory session transcripts live under on this Mac (`…/Nucleic/sessions`).
var transcriptsDir: URL { get }
/// Where the native agent transcript should be placed for a staged session (claude-home +
/// cwd-derived dir), or nil if this destination can't host it (⇒ fresh-context fallback).
func nativeTranscriptDestination(
session: Session, project: Project, worktreePath: String, backendSessionID: String
) -> URL?
/// Activate a freshly-imported session: build its controller and broadcast its arrival.
func didActivateTransferredSession(_ session: Session) async
/// The local mesh-synced mirror of `sessionID`'s transcript, when this device holds one that
/// is **complete through `minSeq`** (full-transcript mesh sync keeps active sessions
/// mirrored on every Mac). A non-nil URL lets the importer adopt the mirror instead of
/// having the source stream the transcript — the "the mesh already synced it" fast path for
/// between-turn Covalence moves. Return nil when unmirrored or behind; the transcript then
/// rides the ordinary chunk stream. Default: nil (hosts without a mirror store).
func mirroredTranscript(sessionID: SessionID, from deviceID: String, minSeq: UInt64) async -> URL?
}
extension TransferImportEnvironment {
public func mirroredTranscript(
sessionID: SessionID, from deviceID: String, minSeq: UInt64
) async -> URL? { nil }
}
/// A concrete `TransferImportEnvironment` the app builds by passing its transcripts dir plus two
/// closures (project resolution + activation). The native-transcript destination is computed
/// purely from the session/project/worktree, so the app doesn't have to reimplement Claude's
/// cwd-dirname convention — keeping the `@MainActor` `AppStore` out of a sync protocol method.
public struct StandardTransferImportEnvironment: TransferImportEnvironment {
public let transcriptsDir: URL
private let resolve: @Sendable (ProjectDescriptor) async -> Project?
private let activate: @Sendable (Session) async -> Void
private let mirror: (@Sendable (SessionID, String, UInt64) async -> URL?)?
public init(
transcriptsDir: URL,
resolveProject: @escaping @Sendable (ProjectDescriptor) async -> Project?,
didActivate: @escaping @Sendable (Session) async -> Void,
mirroredTranscript: (@Sendable (SessionID, String, UInt64) async -> URL?)? = nil
) {
self.transcriptsDir = transcriptsDir
self.resolve = resolveProject
self.activate = didActivate
self.mirror = mirroredTranscript
}
public func resolveProject(_ descriptor: ProjectDescriptor) async -> Project? {
await resolve(descriptor)
}
public func didActivateTransferredSession(_ session: Session) async {
await activate(session)
}
public func mirroredTranscript(
sessionID: SessionID, from deviceID: String, minSeq: UInt64
) async -> URL? {
await mirror?(sessionID, deviceID, minSeq)
}
public func nativeTranscriptDestination(
session: Session, project: Project, worktreePath: String, backendSessionID: String
) -> URL? {
// A sandboxed session writes its native transcript into the per-session claude-home
// (sibling of its transcript); a host run uses ~/.claude. This mirrors
// `SessionController.locateNativeTranscript`'s home choice, and the dir name is the
// canonicalized cwd (identical host vs in-container, so it round-trips).
let claudeHome: String
if ContainerServiceSettings.serviceEnabled, project.effectiveSandbox?.enabled == true {
claudeHome = GitWorktreeManager.canonical(
transcriptsDir.appendingPathComponent(session.id.rawValue, isDirectory: true)
.appendingPathComponent("claude-home", isDirectory: true).path)
} else {
claudeHome = (NSHomeDirectory() as NSString).appendingPathComponent(".claude")
}
let dir = SessionController.claudeProjectDirName(GitWorktreeManager.canonical(worktreePath))
return URL(fileURLWithPath: "\(claudeHome)/projects/\(dir)/\(backendSessionID).jsonl")
}
}