135 lines
6.5 KiB
Swift
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")
|
|
}
|
|
}
|