Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions GraphcodeKit/Sources/Domain/RemoteProjectLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,10 @@ public struct RemoteProjectLocation: Equatable, Sendable {
/// is where the sporadic "ssh failed, retrying" noise on healthy networks came from.
/// A mux channel over a live master cannot fail in transport the way a fresh dial can.
/// `ControlPersist` keeps the master up between ticks; a dead master is redialed by
/// whichever command comes next. If the socket directory is missing ssh just warns and
/// dials directly, so this degrades to the old behaviour, never to a failure.
/// whichever command comes next. A missing socket directory is a hard failure, not a
/// warning: OpenSSH 10.3 exits 255 with `unix_listener: cannot bind to path` and sends
/// nothing (measured on the loopback rig), so every spawn site calls
/// `prepareControlSocketDirectory()` before dialing.
///
/// A Codespace dials through `gh codespace ssh -c <name> -- <ssh-flags> <command>`
/// instead — everything after `--` reaches gh's underlying ssh untouched, so the
Expand Down
198 changes: 140 additions & 58 deletions GraphcodeKit/Sources/ProjectPersistence+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ extension ProjectPersistence {
/// Import is *not* the mirror of this call: the daemon owns the live graph, so a
/// bundle goes back in through `GraphCommand.importNodes`, never by writing the
/// graph file from a client.
///
/// Sessions are read off this Mac's disk — right for a local project. A remote
/// project's sessions are on its host, and this call would leave them behind; the
/// `async` twin below fetches them over the ssh dial.
public func createExportBundle(
for nodeIDs: [UUID],
from graph: LoopGraph,
Expand All @@ -22,95 +26,173 @@ extension ProjectPersistence {
includeMemory: Bool = true,
createdBy: String? = nil
) -> GraphExportBundle? {
var nodeIDsToExport = Set(nodeIDs)
guard let slice = slice(of: graph, for: nodeIDs, includeChildren: includeChildren)
else { return nil }
return bundle(
slice, projectPath: projectPath, includeMemory: includeMemory, createdBy: createdBy,
sessions: Self.sessionArtifacts(for: slice.nodes, projectPath: projectPath))
}

/// The same bundle, with sessions collected wherever the project actually lives: a
/// remote project's are fetched from its host (`SessionTransplant.exportRemoteArtifact`),
/// a local project's are read off this disk exactly as the synchronous call does. The
/// app and the CLI export through this one so an ssh:// or codespace:// export carries
/// its conversations (issue #333).
public func createExportBundle(
for nodeIDs: [UUID],
from graph: LoopGraph,
projectPath: String,
includeChildren: Bool = true,
includeMemory: Bool = true,
createdBy: String? = nil
) async -> GraphExportBundle? {
guard let slice = slice(of: graph, for: nodeIDs, includeChildren: includeChildren)
else { return nil }
let sessions = await Self.remoteAwareSessionArtifacts(
for: slice.nodes, projectPath: projectPath)
return bundle(
slice, projectPath: projectPath, includeMemory: includeMemory, createdBy: createdBy,
sessions: sessions)
}

/// Exports an entire graph as a shareable bundle. Sessions come off this disk, as in
/// `createExportBundle`; the `async` twin reaches a remote project's host.
public func createFullGraphExportBundle(
for graph: LoopGraph,
projectPath: String,
createdBy: String? = nil
) -> GraphExportBundle {
bundle(
Slice(graph: graph, nodes: Array(graph.nodes), isFullGraph: true, includesChildren: true),
projectPath: projectPath, includeMemory: true, createdBy: createdBy,
sessions: Self.sessionArtifacts(for: graph.nodes, projectPath: projectPath))
}

/// The whole graph with sessions collected from wherever the project lives — see the
/// `async` `createExportBundle`.
public func createFullGraphExportBundle(
for graph: LoopGraph,
projectPath: String,
createdBy: String? = nil
) async -> GraphExportBundle {
let sessions = await Self.remoteAwareSessionArtifacts(
for: graph.nodes, projectPath: projectPath)
return bundle(
Slice(graph: graph, nodes: Array(graph.nodes), isFullGraph: true, includesChildren: true),
projectPath: projectPath, includeMemory: true, createdBy: createdBy, sessions: sessions)
}

/// Each exported loop's backend conversation, where one exists and the backend can
/// carry it — see `SessionTransplant`. Local disk only.
static func sessionArtifacts(
for nodes: some Sequence<LoopNode>, projectPath: String
) -> [String: SessionTransplant.Artifact] {
var artifacts: [String: SessionTransplant.Artifact] = [:]
for node in nodes {
if let artifact = SessionTransplant.exportArtifact(forNode: node, projectPath: projectPath) {
artifacts[node.id.uuidString] = artifact
}
}
return artifacts
}

/// How many remote fetches run at once. Each is its own dial — multiplexed over one
/// connection for a plain host, a fresh `gh` tunnel per loop for a Codespace — plus
/// a `tar` and a watchdog; a thirty-loop Codespace graph fetched all at once would be
/// thirty tunnels racing to start a stopped codespace. Four keeps the export quick on
/// a live host without turning it into that.
static let remoteSessionFetchConcurrency = 4

/// `sessionArtifacts` for a project on any host. A remote project's loops are fetched
/// `remoteSessionFetchConcurrency` at a time — the next dial starts as one finishes —
/// and a loop whose fetch comes back empty is simply exported without a session: the
/// export never fails on one.
static func remoteAwareSessionArtifacts(
for nodes: some Sequence<LoopNode>, projectPath: String
) async -> [String: SessionTransplant.Artifact] {
guard let remote = RemoteProjectLocation.parse(projectPath: projectPath) else {
return sessionArtifacts(for: nodes, projectPath: projectPath)
}
return await withTaskGroup(of: (String, SessionTransplant.Artifact?).self) { group in
var artifacts: [String: SessionTransplant.Artifact] = [:]
var inFlight = 0
for node in nodes {
if inFlight == remoteSessionFetchConcurrency, let (nodeID, artifact) = await group.next() {
inFlight -= 1
if let artifact { artifacts[nodeID] = artifact }
}
inFlight += 1
group.addTask {
(
node.id.uuidString,
await SessionTransplant.exportRemoteArtifact(forNode: node, at: remote)
)
}
}
for await (nodeID, artifact) in group {
if let artifact { artifacts[nodeID] = artifact }
}
return artifacts
}
}

/// The part of a graph an export names: the snapshot to write and the loops it holds.
private struct Slice {
var graph: LoopGraph
var nodes: [LoopNode]
var isFullGraph: Bool
var includesChildren: Bool
}

private func slice(of graph: LoopGraph, for nodeIDs: [UUID], includeChildren: Bool) -> Slice? {
var nodeIDsToExport = Set(nodeIDs)
if includeChildren {
for nodeID in nodeIDs {
nodeIDsToExport.formUnion(descendants(of: nodeID, in: graph))
}
}

let exportedNodes = graph.nodes.filter { nodeIDsToExport.contains($0.id) }
guard !exportedNodes.isEmpty else { return nil }

let exportedEdges = graph.edges.filter {
nodeIDsToExport.contains($0.from) && nodeIDsToExport.contains($0.to)
}

let exportGraph = LoopGraph(
id: graph.id,
scope: graph.scope,
nodes: IdentifiedArray(uniqueElements: exportedNodes),
edges: IdentifiedArray(uniqueElements: exportedEdges)
)
return Slice(
graph: exportGraph, nodes: Array(exportedNodes),
isFullGraph: Set(graph.nodes.map(\.id)) == nodeIDsToExport,
includesChildren: includeChildren)
}

private func bundle(
_ slice: Slice, projectPath: String, includeMemory: Bool, createdBy: String?,
sessions: [String: SessionTransplant.Artifact]
) -> GraphExportBundle {
var memoryByNodeID: [String: [String]] = [:]
if includeMemory {
for nodeID in nodeIDsToExport {
let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: nodeID)
for node in slice.nodes {
let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: node.id)
if !entries.isEmpty {
memoryByNodeID[nodeID.uuidString] = entries
memoryByNodeID[node.id.uuidString] = entries
}
}
}

let contents = ExportContents(
nodeIDs: nodeIDsToExport.map(\.uuidString),
includesChildren: includeChildren,
isFullGraph: Set(graph.nodes.map(\.id)) == nodeIDsToExport,
nodeIDs: slice.nodes.map(\.id.uuidString),
includesChildren: slice.includesChildren,
isFullGraph: slice.isFullGraph,
sourceProject: projectPath,
includesMemory: includeMemory
)

return GraphExportBundle(
manifest: ExportManifest(createdBy: createdBy, contents: contents),
graphSnapshot: exportGraph,
memoryByNodeID: memoryByNodeID,
sessionsByNodeID: Self.sessionArtifacts(for: exportedNodes, projectPath: projectPath)
)
}

/// Each exported loop's backend conversation, where one exists and the backend can
/// carry it — see `SessionTransplant`.
static func sessionArtifacts(
for nodes: some Sequence<LoopNode>, projectPath: String
) -> [String: SessionTransplant.Artifact] {
var artifacts: [String: SessionTransplant.Artifact] = [:]
for node in nodes {
if let artifact = SessionTransplant.exportArtifact(forNode: node, projectPath: projectPath) {
artifacts[node.id.uuidString] = artifact
}
}
return artifacts
}

/// Exports an entire graph as a shareable bundle.
public func createFullGraphExportBundle(
for graph: LoopGraph,
projectPath: String,
createdBy: String? = nil
) -> GraphExportBundle {
var memoryByNodeID: [String: [String]] = [:]
for node in graph.nodes {
let entries = NodeMemory.entries(forProjectPath: projectPath, nodeID: node.id)
if !entries.isEmpty {
memoryByNodeID[node.id.uuidString] = entries
}
}

let contents = ExportContents(
nodeIDs: graph.nodes.map { $0.id.uuidString },
includesChildren: true,
isFullGraph: true,
sourceProject: projectPath,
includesMemory: true
)

return GraphExportBundle(
manifest: ExportManifest(createdBy: createdBy, contents: contents),
graphSnapshot: graph,
graphSnapshot: slice.graph,
memoryByNodeID: memoryByNodeID,
sessionsByNodeID: Self.sessionArtifacts(for: graph.nodes, projectPath: projectPath)
sessionsByNodeID: sessions
)
}

Expand Down
Loading
Loading