diff --git a/FrameworkTests/TailscaleIntegrationTests.swift b/FrameworkTests/TailscaleIntegrationTests.swift index 09dd91cd..3fbb99a3 100644 --- a/FrameworkTests/TailscaleIntegrationTests.swift +++ b/FrameworkTests/TailscaleIntegrationTests.swift @@ -586,7 +586,10 @@ struct TailscaleIntegrationTests { try await Task.sleep(for: .milliseconds(10)) } #expect(await node.selectedExitNode == "exit-one") - #expect(model.exitNodeCandidates(groupID: group.id).map(\.stableID) == ["exit-one"]) + #expect( + TailscaleGroupEditor(model: model, group: library.group(group.id)).candidates.map(\.stableID) == [ + "exit-one" + ]) #expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" }) let retained = try model.connection(for: group.id) group.tailscale?.exitNodeID = "exit-two" @@ -599,7 +602,9 @@ struct TailscaleIntegrationTests { try await Task.sleep(for: .milliseconds(10)) } #expect(await node.selectedExitNode == "exit-two") - try model.saveExitNode("", groupID: group.id) + let editor = TailscaleGroupEditor(model: model, group: library.group(group.id)) + editor.exitNodeID = "" + _ = try await editor.save() for _ in 0..<400 { if await node.selectedExitNode == nil { break } try await Task.sleep(for: .milliseconds(10)) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 519c302f..859dd527 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -34305,23 +34305,6 @@ } } }, - "Waiting for a Telnet password" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在等待Telnet密码" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在等待Telnet密碼" - } - } - } - }, "Waiting for an Identity" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift index 39f83737..7da99630 100644 --- a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift +++ b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift @@ -287,16 +287,6 @@ public final class AssistantLibraryModel { return draftConversation } - /// Promotes the New Chat scratch model on its first send, then prepares a - /// fresh scratch model for the next visit to New Chat. - @discardableResult - public func startDraftConversation() -> ConversationModel? { - let conversation = draftConversation - guard let started = startConversation(conversation) else { return nil } - draftConversation = newConversation() - return started - } - public func canPromote(_ conversation: ConversationModel) -> Bool { !conversations.contains(where: { $0.id == conversation.id }) } @@ -326,19 +316,6 @@ public final class AssistantLibraryModel { return conversation } - /// Promotes the sidebar's scratch conversation into history on its first - /// real send. Empty drafts remain ephemeral and never create blank rows. - @discardableResult - public func startConversation(_ conversation: ConversationModel) -> ConversationModel? { - guard conversation.canSendDraft else { return nil } - guard !conversations.contains(where: { $0.id == conversation.id }) else { return nil } - - conversation.sendDraft() - store.insert(conversation) - selectedConversationID = conversation.id - return conversation - } - public func select(_ id: ConversationModel.ID) { guard conversations.contains(where: { $0.id == id }) else { return } selectedConversationID = id diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 7e97c737..8bda4e41 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -1974,8 +1974,8 @@ public final class ConversationModel: Identifiable { composer.pendingAttachments.removeAll { $0.id == id } } - /// Records a local user turn. Agent-backed composers use AppModel's runtime - /// path; the sidebar promotion flow uses this before starting that run. + /// Records a local user turn. Agent-backed composers capture and submit + /// their turns through `AssistantExecutionController`. public func sendDraft() { let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) guard canSendDraft else { return } diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index 7c21f3b6..cbca3540 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -61,7 +61,6 @@ public final class ConnectionJourney { public enum LocalRequirement: Equatable { case identity case tailscaleUsername - case telnetPassword case passphrase case proxyAuthentication } @@ -98,7 +97,6 @@ public final class ConnectionJourney { public var waitsForLocalIdentity: Bool { localRequirement == .identity } public var waitsForLocalPassphrase: Bool { localRequirement == .passphrase } public var waitsForProxyAuthentication: Bool { localRequirement == .proxyAuthentication } - public var waitsForTelnetPassword: Bool { localRequirement == .telnetPassword } private static var localDeviceName: String { #if os(macOS) @@ -354,20 +352,6 @@ public final class ConnectionJourney { ) } - public func waitForTelnetPassword(at hostID: Host.ID, failure: String? = nil) { - guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return } - activeEndpointIndex = 0 - completedEndpointIndex = 0 - checkpoint = nil - localRequirement = .telnetPassword - localRequirementEndpointIndex = 0 - phase = .waiting - log( - endpoint: endpoints[index].name, - message: failure ?? String(localized: "Waiting for a Telnet password") - ) - } - public func waitForHostKey(at hostID: Host.ID, fingerprint: String) { wait( at: hostID, kind: .hostKey, diff --git a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift index 9f64d4bd..bd47b820 100644 --- a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift @@ -149,22 +149,6 @@ public final class TailscaleModel: TailscaleConnectionProviding { public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" } - public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] { - (peers[groupID] ?? []).filter { $0.exitNodeOption == true } - .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - } - - public func saveExitNode(_ stableID: String, groupID: UUID) throws { - guard hasAccess() else { throw TailscaleConfigurationError.requiresPro } - guard var group = library.group(groupID), group.tailscale != nil else { - throw TailscaleConfigurationError.unavailable - } - guard exitNodeID(groupID: groupID) != stableID else { return } - group.tailscale?.exitNodeID = stableID.isEmpty ? nil : stableID - library.upsert(group) - if let error = library.storageError { throw TermindSSHError.library(error) } - } - public func saveHostname(_ value: String, groupID: UUID) async throws { guard hasAccess() else { throw TailscaleConfigurationError.requiresPro } guard var state = states[groupID] else { throw TailscaleConfigurationError.unavailable } diff --git a/Frameworks/TermindApplication/HostRepository.swift b/Frameworks/TermindApplication/HostRepository.swift index b093fbbf..5678862f 100644 --- a/Frameworks/TermindApplication/HostRepository.swift +++ b/Frameworks/TermindApplication/HostRepository.swift @@ -55,7 +55,6 @@ public protocol HostRepository: AnyObject { func changes() -> AsyncStream> func refresh() throws func upsertHost(_ host: Host) throws - func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact func requestHostDeletion(_ hostID: Host.ID) throws func clearReferencesForDeletedHost(_ hostID: Host.ID) throws func finalizeHostDeletion(_ hostID: Host.ID) throws diff --git a/Frameworks/TermindDomain/Hosts/HostDeletion.swift b/Frameworks/TermindDomain/Hosts/HostDeletion.swift index 9432df07..2e4dad8c 100644 --- a/Frameworks/TermindDomain/Hosts/HostDeletion.swift +++ b/Frameworks/TermindDomain/Hosts/HostDeletion.swift @@ -1,17 +1,5 @@ import Foundation -public nonisolated struct HostDeletionImpact: Equatable, Sendable { - public var jumpHostReferences: Int - public var snippetReferences: Int - public var forwardingRules: Int - public init(jumpHostReferences: Int, snippetReferences: Int, forwardingRules: Int) { - self.jumpHostReferences = jumpHostReferences - self.snippetReferences = snippetReferences - self.forwardingRules = forwardingRules - } - -} - public nonisolated struct HostDeletionPayload: Codable, Hashable, Sendable { public var hostID: Host.ID public var forwardingRuleIDs: [PortForwardingRule.ID] diff --git a/Frameworks/TermindInfrastructure/InMemoryHostRepository.swift b/Frameworks/TermindInfrastructure/InMemoryHostRepository.swift index a58c91cf..710f6860 100644 --- a/Frameworks/TermindInfrastructure/InMemoryHostRepository.swift +++ b/Frameworks/TermindInfrastructure/InMemoryHostRepository.swift @@ -50,13 +50,6 @@ public final class InMemoryHostRepository: HostRepository { try publish(.mutation) } - public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact { - HostDeletionImpact( - jumpHostReferences: value.hosts.filter { $0.jumpHostID == hostID }.count, - snippetReferences: 0, forwardingRules: 0 - ) - } - public func requestHostDeletion(_ hostID: Host.ID) throws { value.hosts.removeAll { $0.id == hostID } try publish(.mutation) diff --git a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift index 86a3d1f5..fae69d04 100644 --- a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift @@ -63,17 +63,6 @@ public final class SwiftDataHostRepository: HostRepository { try commitMutation() } - public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact { - let hosts = try context.fetch(FetchDescriptor()) - let snippets = try context.fetch(FetchDescriptor()) - let rules = try forwardingRules(for: hostID) - return HostDeletionImpact( - jumpHostReferences: hosts.filter { $0.jumpHostID == hostID }.count, - snippetReferences: snippets.filter { $0.targetHostIDs.contains(hostID) }.count, - forwardingRules: rules.count - ) - } - /// The deletion marker, all durable side effects, and the revision become /// visible in one database commit. A crash can therefore see either the /// live host or a complete deletion saga, never half of each. diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift index f9232487..6711a6c9 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -219,7 +219,6 @@ private struct JourneyEndpointView: View { if let localRequirement { switch localRequirement { case .identity, .tailscaleUsername: return "person.fill" - case .telnetPassword: return "key.fill" case .passphrase: return "key.fill" case .proxyAuthentication: return "lock.shield.fill" } diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPConcurrencyScheduler.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPConcurrencyScheduler.swift index ec348045..61f95bd6 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPConcurrencyScheduler.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPConcurrencyScheduler.swift @@ -38,15 +38,9 @@ public actor SFTPConcurrencyScheduler { var hostIDs: Set var source: SFTPWorkSource var byteCount: Int64? - var exclusiveResourceIDs: Set var continuation: CheckedContinuation } - private struct ActiveRequest { - var hostIDs: Set - var exclusiveResourceIDs: Set - } - private var preferences: SFTPPreferences private var requests: [UUID: Request] = [:] private var queues: [UUID: [SFTPWorkSource: [UUID]]] = [:] @@ -54,9 +48,8 @@ public actor SFTPConcurrencyScheduler { private var sourceOrder: [UUID: [SFTPWorkSource]] = [:] private var lastGrantedHost: UUID? private var lastGrantedSource: [UUID: SFTPWorkSource] = [:] - private var active: [UUID: ActiveRequest] = [:] + private var active: [UUID: Set] = [:] private var activePerHost: [UUID: Int] = [:] - private var activeExclusiveResources: Set = [] public init(preferences: SFTPPreferences = SFTPPreferences()) { self.preferences = preferences.normalized @@ -71,8 +64,7 @@ public actor SFTPConcurrencyScheduler { anchorHostID: UUID, hostIDs: Set, source: SFTPWorkSource, - byteCount: Int64?, - exclusiveResourceIDs: Set = [] + byteCount: Int64? ) async throws -> SFTPConcurrencyPermit { try Task.checkCancellation() let id = UUID() @@ -85,7 +77,6 @@ public actor SFTPConcurrencyScheduler { hostIDs: normalizedHosts, source: source, byteCount: byteCount, - exclusiveResourceIDs: exclusiveResourceIDs, continuation: continuation ) requests[id] = request @@ -121,12 +112,11 @@ public actor SFTPConcurrencyScheduler { } private func release(_ id: UUID) { - guard let request = active.removeValue(forKey: id) else { return } - for hostID in request.hostIDs { + guard let hostIDs = active.removeValue(forKey: id) else { return } + for hostID in hostIDs { let remaining = max(0, activePerHost[hostID, default: 1] - 1) activePerHost[hostID] = remaining == 0 ? nil : remaining } - activeExclusiveResources.subtract(request.exclusiveResourceIDs) drain() } @@ -135,11 +125,8 @@ public actor SFTPConcurrencyScheduler { requests[request.id] = nil removeQueuedID(request.id, request: request) let permitID = UUID() - active[permitID] = ActiveRequest( - hostIDs: request.hostIDs, - exclusiveResourceIDs: request.exclusiveResourceIDs) + active[permitID] = request.hostIDs for hostID in request.hostIDs { activePerHost[hostID, default: 0] += 1 } - activeExclusiveResources.formUnion(request.exclusiveResourceIDs) lastGrantedHost = request.anchorHostID lastGrantedSource[request.anchorHostID] = request.source let mode = schedulingMode(byteCount: request.byteCount) @@ -159,7 +146,6 @@ public actor SFTPConcurrencyScheduler { guard let id = hostQueues[source]?.first, let request = requests[id] else { continue } guard request.hostIDs.allSatisfy({ activePerHost[$0, default: 0] < preferences.perHostLimit }) else { continue } - guard request.exclusiveResourceIDs.isDisjoint(with: activeExclusiveResources) else { continue } return request } } @@ -202,7 +188,7 @@ public actor SFTPConcurrencyScheduler { queues[hostID] = hostQueues } } - let activeHosts = Set(active.values.flatMap(\.hostIDs)) + let activeHosts = Set(active.values.flatMap { $0 }) for hostID in Array(lastGrantedSource.keys) where queues[hostID] == nil && !activeHosts.contains(hostID) { lastGrantedSource[hostID] = nil diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift index c588819e..c1d180ec 100644 --- a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -12,15 +12,10 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable { public nonisolated enum SFTPTransferConnection: Sendable { case saved(SSHAdditionalConnectionSnapshot) case context(SFTPTransferContext) - case borrowed(id: UUID, channel: any SFTPChannelProtocol) public var managedContext: SFTPTransferContext? { if case .context(let context) = self { context } else { nil } } - - public var borrowedID: UUID? { - if case .borrowed(let id, _) = self { id } else { nil } - } } public nonisolated struct SFTPRemoteCopySource: Sendable { @@ -116,7 +111,6 @@ public final class SFTPTransferCoordinator { private var preferencesUpdateTask: Task? private var activeTasks: [SFTPTransfer.ID: Task] = [:] private var activePermits: [SFTPTransfer.ID: SFTPConcurrencyPermit] = [:] - private var activeBorrowedConnections: Set = [] private var remoteCopySources: [UUID: (source: SFTPRemoteCopySource, createdAt: Date)] = [:] public private(set) var globalLimit: Int @@ -214,13 +208,6 @@ public final class SFTPTransferCoordinator { } let reservation = UUID() retain(source.connection, for: reservation) - let borrowedID = source.connection.borrowedID - if let borrowedID { - try await reserveBorrowedConnection(borrowedID) - } - defer { - if let borrowedID { releaseBorrowedConnections([borrowedID]) } - } var lease: ChannelLease? do { let acquired = try await clientLease(for: source.connection) @@ -343,12 +330,6 @@ public final class SFTPTransferCoordinator { ) } - public func replaceBorrowedChannel(_ connectionID: UUID, with channel: any SFTPChannelProtocol) { - for id in Array(runtimes.keys) where runtimes[id]?.connection.borrowedID == connectionID { - runtimes[id]?.connection = .borrowed(id: connectionID, channel: channel) - } - } - public func canPause(_ id: SFTPTransfer.ID) -> Bool { guard let transfer = store.sftpTransfer(id) else { return false } return runtimes[id] != nil && transfer.state.isActive @@ -430,8 +411,6 @@ public final class SFTPTransferCoordinator { try await runtime.resume.cleanup(using: channel) } else { do { - if let id = runtime.connection.borrowedID { try await reserveBorrowedConnection(id) } - defer { if let id = runtime.connection.borrowedID { releaseBorrowedConnections([id]) } } let lease = try await clientLease(for: runtime.connection) do { try await runtime.resume.cleanup(using: lease.channel) } catch { await lease.disconnect() @@ -466,21 +445,6 @@ public final class SFTPTransferCoordinator { } } - public func cancelTransfers(usingBorrowedConnection connectionID: UUID) async { - let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in - guard !transfer.state.isFinished, - borrowedIDs(for: transfer).contains(connectionID) - else { return nil } - return transfer.id - } - for id in ids { cancel(id) } - let tasks = ids.compactMap { activeTasks[$0] } - for task in tasks { await task.value } - while activeBorrowedConnections.contains(connectionID) { - try? await Task.sleep(for: .milliseconds(10)) - } - } - public func clearFinished() { store.clearFinishedSFTPTransfers() } public func removeFinished(_ id: SFTPTransfer.ID) { store.removeFinishedSFTPTransfer(id) } @@ -494,7 +458,6 @@ public final class SFTPTransferCoordinator { let hostIDs = Set(hostIDs(for: sftpTransfer)) guard let anchorHostID = anchorHostID(for: sftpTransfer), !hostIDs.isEmpty else { continue } let source = workSource(for: sftpTransfer) - let exclusiveResourceIDs = Set(borrowedIDs(for: sftpTransfer)) let scheduler = concurrencyScheduler let preferencesUpdateTask = preferencesUpdateTask admissionTasks[sftpTransfer.id] = Task { @MainActor [weak self] in @@ -504,8 +467,7 @@ public final class SFTPTransferCoordinator { anchorHostID: anchorHostID, hostIDs: hostIDs, source: source, - byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil, - exclusiveResourceIDs: exclusiveResourceIDs + byteCount: sftpTransfer.byteCount >= 0 ? sftpTransfer.byteCount : nil ) guard let self else { await permit.release() @@ -534,10 +496,8 @@ public final class SFTPTransferCoordinator { startRemoteCopy(sftpTransfer, runtime: runtime, mode: mode) return } - guard let hostID = sftpTransfer.hostID, let runtime = runtimes[sftpTransfer.id] else { return } + guard let runtime = runtimes[sftpTransfer.id] else { return } store.setSFTPTransferState(.running, for: sftpTransfer.id) - let borrowedIDs = Array(runtime.connection.borrowedID.map { [$0] } ?? []) - activeBorrowedConnections.formUnion(borrowedIDs) let task = Task { [weak self] in guard let self else { return } let scoped = runtime.scopedRoot.startAccessingSecurityScopedResource() @@ -606,10 +566,7 @@ public final class SFTPTransferCoordinator { totalBytes: snapshot.source?.byteCount ?? sftpTransfer.byteCount, bytesPerSecond: 0) } await lease?.disconnect() - await finish( - sftpTransfer.id, hostIDs: [hostID], borrowedIDs: borrowedIDs, - state: result - ) + await finish(sftpTransfer.id, state: result) } activeTasks[sftpTransfer.id] = task } @@ -626,13 +583,7 @@ public final class SFTPTransferCoordinator { runtime: RemoteCopyRuntime, mode: SSHChannelSchedulingMode ) { - let involvedHostIDs = Array(Set([runtime.sourceHostID, runtime.destinationHostID])) store.setSFTPTransferState(.running, for: sftpTransfer.id) - let borrowedIDs = [ - runtime.sourceConnection.borrowedID, - runtime.destinationConnection.borrowedID, - ].compactMap { $0 } - activeBorrowedConnections.formUnion(borrowedIDs) let task = Task { [weak self] in guard let self else { return } var sourceLease: ChannelLease? @@ -687,10 +638,7 @@ public final class SFTPTransferCoordinator { } await sourceLease?.disconnect() await destinationLease?.disconnect() - await finish( - sftpTransfer.id, hostIDs: involvedHostIDs, - borrowedIDs: borrowedIDs, state: result - ) + await finish(sftpTransfer.id, state: result) } activeTasks[sftpTransfer.id] = task } @@ -748,8 +696,6 @@ public final class SFTPTransferCoordinator { throw error } return ChannelLease(channel: client) { await client.disconnect() } - case .borrowed(_, let channel): - return ChannelLease(channel: channel) {} } } @@ -771,32 +717,8 @@ public final class SFTPTransferCoordinator { .sorted { $0.id.uuidString < $1.id.uuidString } } - private func borrowedIDs(for sftpTransfer: SFTPTransfer) -> [UUID] { - if let runtime = remoteCopyRuntimes[sftpTransfer.id] { - return [ - runtime.sourceConnection.borrowedID, - runtime.destinationConnection.borrowedID, - ].compactMap { $0 } - } - return runtimes[sftpTransfer.id]?.connection.borrowedID.map { [$0] } ?? [] - } - - private func reserveBorrowedConnection(_ id: UUID) async throws { - while activeBorrowedConnections.contains(id) { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(10)) - } - activeBorrowedConnections.insert(id) - } - - private func releaseBorrowedConnections(_ ids: [UUID]) { - activeBorrowedConnections.subtract(ids) - schedule() - } - private func finish( - _ id: SFTPTransfer.ID, hostIDs: [Host.ID], borrowedIDs: [UUID] = [], - state: SFTPTransfer.State + _ id: SFTPTransfer.ID, state: SFTPTransfer.State ) async { for context in managedContexts(for: id) { context.releaseSlot() @@ -813,7 +735,6 @@ public final class SFTPTransferCoordinator { } activeTasks.removeValue(forKey: id) let permit = activePermits.removeValue(forKey: id) - releaseBorrowedConnections(borrowedIDs) await permit?.release() schedule() } diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 18096f16..2ed1e7ff 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -266,7 +266,6 @@ struct MobileAssistantTests { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() - _ = assistant.library.startConversation(conversation) let plan = AgentPlan( title: "Implementation plan", body: "Apply the requested change.", scratchPath: "/tmp/plan.md") @@ -295,7 +294,8 @@ struct MobileAssistantTests { let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() conversation.composer.draft = "Opening question" - _ = assistant.library.startConversation(conversation) + conversation.sendDraft() + assistant.library.insertAndSelect(conversation) conversation.composer.draft = "Follow-up question" assistant.send() @@ -537,7 +537,6 @@ struct MobileAssistantTests { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() let conversation = ConversationModel() - _ = assistant.library.startConversation(conversation) let plan = AgentPlan( title: "Implementation plan", body: "Apply the requested change.", scratchPath: "/tmp/plan.md") diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 9106b455..f5439fdf 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -23,7 +23,8 @@ struct MobileWorkspaceSessionTests { other.agentForwarding.isActive = true let conversation = ConversationModel() conversation.composer.draft = "Agent forwarding" - _ = try #require(owner.assistant.library.startConversation(conversation)) + conversation.sendDraft() + owner.assistant.library.insertAndSelect(conversation) owner.openAssistantNotification(conversation.id) let hostID = UUID() let request = SSHConnectionRequest( @@ -59,7 +60,8 @@ struct MobileWorkspaceSessionTests { ].sorted { $0.id.uuidString < $1.id.uuidString } let conversation = ConversationModel() conversation.composer.draft = "Target" - _ = try #require(scenes[0].assistant.library.startConversation(conversation)) + conversation.sendDraft() + scenes[0].assistant.library.insertAndSelect(conversation) for scene in scenes { scene.openAssistantNotification(conversation.id) if !visible { scene.selectedDestination = .vault } @@ -85,7 +87,8 @@ struct MobileWorkspaceSessionTests { let other = MobileSceneModel(app: app, sessionID: "active-other") let conversation = ConversationModel() conversation.composer.draft = "Target" - _ = try #require(owner.assistant.library.startConversation(conversation)) + conversation.sendDraft() + owner.assistant.library.insertAndSelect(conversation) owner.openAssistantNotification(conversation.id) if libraryPresented { owner.assistantLibraryRoute = .conversations @@ -155,8 +158,10 @@ struct MobileWorkspaceSessionTests { let second = ConversationModel() first.composer.draft = "First" second.composer.draft = "Second" - _ = try #require(scene.assistant.library.startConversation(first)) - _ = try #require(scene.assistant.library.startConversation(second)) + first.sendDraft() + scene.assistant.library.insertAndSelect(first) + second.sendDraft() + scene.assistant.library.insertAndSelect(second) app.sceneChanged(scene.id, phase: .active) var activations: [String] = [] scene.assistant.library.select(first.id) @@ -258,7 +263,8 @@ struct MobileWorkspaceSessionTests { let second = MobileSceneModel(app: app, sessionID: "assistant-b") let conversation = ConversationModel() conversation.composer.draft = "Test" - _ = try #require(first.assistant.library.startConversation(conversation)) + conversation.sendDraft() + first.assistant.library.insertAndSelect(conversation) first.openAssistantNotification(conversation.id) app.sceneChanged(first.id, phase: .active) #expect(app.isAssistantNotificationFocused(conversation.id)) @@ -268,7 +274,8 @@ struct MobileWorkspaceSessionTests { app.sceneChanged(second.id, phase: .active) let peer = ConversationModel() peer.composer.draft = "Second conversation" - _ = try #require(second.assistant.library.startConversation(peer)) + peer.sendDraft() + second.assistant.library.insertAndSelect(peer) second.openAssistantNotification(peer.id) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(app.isAssistantNotificationFocused(peer.id)) diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index d1feb3c3..51b02085 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -60,7 +60,6 @@ final class AppModel { let credentials: CredentialLibraryModel let snippets: SnippetLibraryModel let forwardings: ForwardingLibraryModel - let transfers: TransferLibraryModel let connectionHistory: ConnectionHistoryModel let devices: DeviceDirectoryModel private let externalToolCalls: ToolAuditModel @@ -238,7 +237,6 @@ final class AppModel { credentials = CredentialLibraryModel(library: hosts) snippets = SnippetLibraryModel(library: hosts) forwardings = ForwardingLibraryModel(library: hosts) - transfers = TransferLibraryModel(library: hosts) self.connectionHistory = connectionHistory let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID) self.devices = devices diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift deleted file mode 100644 index 734a88bd..00000000 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ /dev/null @@ -1,137 +0,0 @@ -import Foundation -import TermindApplication -import TermindDomain - -import struct TermindDomain.Host - -/// Saved-vault access exposed to transfer presentation and session entry points. -@Observable -final class TransferLibraryModel { - private let library: HostLibraryModel - - init(library: HostLibraryModel) { - self.library = library - } -} - -extension TransferLibraryModel: RemoteSessionHostStore { - func host(_ id: Host.ID) -> Host? { library.host(id) } - func networkConfigurationStamp(for host: Host) -> Data { library.networkConfigurationStamp(for: host) } - func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? { - try library.tailscaleNetwork(for: host) - } - func savedTailscaleUsername(for host: Host) -> String? { library.savedTailscaleUsername(for: host) } - func tailscaleUsername(for host: Host) -> String { library.tailscaleUsername(for: host) } - @discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool { - library.saveTailscaleUsername(username, hostID: hostID) - } - func identity(_ id: Identity.ID) -> Identity? { library.identity(id) } - func identity(for host: Host) -> Identity? { library.identity(for: host) } - func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { - library.customIdentity(for: hostID, purpose: purpose) - } - var customPasswordStorage: PasswordStorage { library.customPasswordStorage } - func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) } - func knownHosts(for host: Host) -> [KnownHostEntry] { library.knownHosts(for: host) } - func route(for host: Host) -> [Host] { library.route(for: host) } - func expectedHostKeys( - for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], - trustPolicy: SSHHostTrustPolicy - ) throws -> Set { - try library.expectedHostKeys( - for: host, temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy - ) - } - func prepareSSHConnection( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], - proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] - ) throws -> SSHConnectionPreparation { - try library.prepareSSHConnection( - for: host, submissions: submissions, proxySubmissions: proxySubmissions) - } - func sshConnectionConfiguration( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], - proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], - trustPolicy: SSHHostTrustPolicy - ) throws -> SSHConnectionConfiguration { - try library.sshConnectionConfiguration( - for: host, submissions: submissions, proxySubmissions: proxySubmissions, - temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy - ) - } - func sshJumpConfigurations( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], - proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], - trustPolicy: SSHHostTrustPolicy - ) throws -> [SSHRouteHopConfiguration] { - try library.sshJumpConfigurations( - for: host, submissions: submissions, proxySubmissions: proxySubmissions, - temporaryHostKeys: temporaryHostKeys, - trustPolicy: trustPolicy - ) - } - func prepareSSHConnection( - for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission], - proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission] - ) throws -> SSHConnectionPreparation { - try library.prepareSSHConnection( - for: route, submissions: submissions, proxySubmissions: proxySubmissions) - } - func sshJumpConfigurations( - for route: SSHConnectionRoute, submissions: [Host.ID: SSHAuthenticationSubmission], - proxySubmissions: [Host.ID: SSHProxyAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity], trustPolicy: SSHHostTrustPolicy - ) throws -> [SSHRouteHopConfiguration] { - try library.sshJumpConfigurations( - for: route, submissions: submissions, proxySubmissions: proxySubmissions, - temporaryHostKeys: temporaryHostKeys, trustPolicy: trustPolicy - ) - } - func saveConnectionRoute( - _ route: SSHConnectionRoute, authentication: SSHAuthenticationSubmission?, - authenticationHostID: Host.ID?, suppliedPassword: String? - ) throws -> SSHConnectionRoute { - try library.saveConnectionRoute( - route, authentication: authentication, authenticationHostID: authenticationHostID, - suppliedPassword: suppliedPassword - ) - } - func transportProxy(for host: Host) throws -> SSHTransportProxy? { - try library.transportProxy(for: host) - } - func transportProxy( - for host: Host, submission: SSHProxyAuthenticationSubmission? - ) throws -> SSHTransportProxy? { - try library.transportProxy(for: host, submission: submission) - } - func sshCredentials(for identity: Identity) -> SSHCredentialResolution { - library.sshCredentials(for: identity) - } - func passwordSecret(for identity: Identity) throws -> SSHSecret? { - try library.passwordSecret(for: identity) - } - func passwordAvailability(for identity: Identity) -> SecretAvailability { - library.passwordAvailability(for: identity) - } - func keySecretCredential(for key: SSHKeyRef) throws -> SSHResolvedKeyCredential? { - try library.keySecretCredential(for: key) - } - func setConnectionStatus( - _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID - ) { - library.setConnectionStatus(status, connectionID: connectionID, hostID: hostID) - } - func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) { - library.recordRemoteSystemInfo(systemInfo, for: hostID) - } - func trust(_ entry: KnownHostEntry) { library.trust(entry) } - @discardableResult func upsert(_ identity: Identity) -> Bool { library.upsert(identity) } - func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { - try library.upsert(identity, passwordUpdate: passwordUpdate) - } - func upsert(_ host: Host) { library.upsert(host) } -} diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index 70c413b2..3bb37235 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -309,17 +309,17 @@ struct SFTPBrowserView: View { identityID, username, password, passphrase, keyID, save in sftpChannel.continueAuthentication( identityID: identityID, username: username, password: password, - passphrase: passphrase, keyID: keyID, save: save, using: app.transfers + passphrase: passphrase, keyID: keyID, save: save, using: app.hostLibrary ) }, continueHostKey: { sftpChannel.continueWithTemporaryHostKey($0) }, - trustHostKey: { sftpChannel.trustAndConnect($0, using: app.transfers) }, + trustHostKey: { sftpChannel.trustAndConnect($0, using: app.hostLibrary) }, denyHostKey: { cancelConnection() }, - retry: { sftpChannel.connect(using: app.transfers) }, + retry: { sftpChannel.connect(using: app.hostLibrary) }, cancel: cancelConnection, reconnectState: sftpChannel.reconnectState, retryReconnectNow: sftpChannel.retryReconnectNow, @@ -337,7 +337,7 @@ struct SFTPBrowserView: View { .background(TermindColor.windowSurface) .onAppear { sftpChannel.bindTransfers(to: app.sftpTransfers) - sftpChannel.connectIfNeeded(using: app.transfers) + sftpChannel.connectIfNeeded(using: app.hostLibrary) sftpChannel.startAutomaticRefresh() } .alert(AppStrings.SFTP.newFolder, isPresented: $newFolderPresented) { @@ -630,7 +630,7 @@ struct SFTPBrowserView: View { Image(systemName: "folder.badge.questionmark") .font(.largeTitle).foregroundStyle(.tertiary) Text(AppStrings.Connections.notConnected).foregroundStyle(.secondary) - Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.transfers) } + Button(AppStrings.Common.connect.swiftUIKey) { sftpChannel.connect(using: app.hostLibrary) } .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index c23877e7..fa2f1201 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -764,15 +764,6 @@ final class WindowModel { assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable) } - @discardableResult - func sendAssistantSidebarDraft() -> ConversationModel? { - let draft = sidebar.assistantDraft - guard let conversation = assistantLibrary.startConversation(draft) else { return nil } - isAssistantArchivePresented = false - sidebar.assistantDraft = ConversationModel() - return conversation - } - func assistantSidebarConversationDidStart(_ conversation: ConversationModel) { isAssistantArchivePresented = false assistantLibrary.select(conversation.id) diff --git a/TFMTests/CommandNotificationRenderingTests.swift b/TFMTests/CommandNotificationRenderingTests.swift index dbeec178..e4e64138 100644 --- a/TFMTests/CommandNotificationRenderingTests.swift +++ b/TFMTests/CommandNotificationRenderingTests.swift @@ -17,7 +17,8 @@ struct CommandNotificationRenderingTests { app.registerWindow(windowModel) let conversation = ConversationModel() conversation.composer.draft = "Deploy staging" - _ = try #require(windowModel.assistantLibrary.startConversation(conversation)) + conversation.sendDraft() + windowModel.assistantLibrary.insertAndSelect(conversation) let coordinator = AssistantNotificationCoordinator( service: app.notifications, preferences: { .init() }, isFocused: { _ in false }, content: AssistantNotificationContent.make) diff --git a/TFMTests/CommandNotificationTests.swift b/TFMTests/CommandNotificationTests.swift index c8d3f25d..be10c993 100644 --- a/TFMTests/CommandNotificationTests.swift +++ b/TFMTests/CommandNotificationTests.swift @@ -16,7 +16,8 @@ struct CommandNotificationTests { app.registerWindow(window) let conversation = ConversationModel() conversation.composer.draft = "Target" - _ = try #require(window.assistantLibrary.startConversation(conversation)) + conversation.sendDraft() + window.assistantLibrary.insertAndSelect(conversation) let workspace = Workspace(pane: Pane(.empty)) workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty)) window.open(workspace) @@ -30,7 +31,8 @@ struct CommandNotificationTests { case "peer": let peer = ConversationModel() peer.composer.draft = "Other conversation" - _ = try #require(window.assistantLibrary.startConversation(peer)) + peer.sendDraft() + window.assistantLibrary.insertAndSelect(peer) case "archive": window.showAssistantArchiveInSidebar() default: break } @@ -55,7 +57,8 @@ struct CommandNotificationTests { for window in windows { app.registerWindow(window) } let conversation = ConversationModel() conversation.composer.draft = "Target" - _ = try #require(windows[0].assistantLibrary.startConversation(conversation)) + conversation.sendDraft() + windows[0].assistantLibrary.insertAndSelect(conversation) for window in windows { window.openAssistantFullScreen(conversation.id) if !visible { window.selectVault() } @@ -79,7 +82,8 @@ struct CommandNotificationTests { app.registerWindow(other) let conversation = ConversationModel() conversation.composer.draft = "Target" - _ = try #require(owner.assistantLibrary.startConversation(conversation)) + conversation.sendDraft() + owner.assistantLibrary.insertAndSelect(conversation) owner.openAssistantFullScreen(conversation.id) let workspace = Workspace(pane: Pane(.empty)) owner.open(workspace) @@ -105,8 +109,10 @@ struct CommandNotificationTests { let second = ConversationModel() first.composer.draft = "First" second.composer.draft = "Second" - _ = try #require(window.assistantLibrary.startConversation(first)) - _ = try #require(window.assistantLibrary.startConversation(second)) + first.sendDraft() + window.assistantLibrary.insertAndSelect(first) + second.sendDraft() + window.assistantLibrary.insertAndSelect(second) let activations = AsyncStream.makeStream() var count = 0 let router = DesktopNotificationRouter(app: app) { _ in @@ -213,7 +219,8 @@ struct CommandNotificationTests { app.registerWindow(other) let conversation = ConversationModel() conversation.composer.draft = "Test" - _ = try #require(window.assistantLibrary.startConversation(conversation)) + conversation.sendDraft() + window.assistantLibrary.insertAndSelect(conversation) window.openAssistantFullScreen(conversation.id) #expect(app.isAssistantNotificationFocused(conversation.id)) #expect(app.isAssistantNotificationFocused(UUID())) @@ -229,7 +236,8 @@ struct CommandNotificationTests { #expect(other.assistantLibrary.selectedConversation === conversation) let peer = ConversationModel() peer.composer.draft = "Second conversation" - _ = try #require(window.assistantLibrary.startConversation(peer)) + peer.sendDraft() + window.assistantLibrary.insertAndSelect(peer) window.openAssistantInSidebar(peer.id) window.showsSidebar = true other.open(Workspace(pane: Pane(.empty))) diff --git a/TFMTests/DesktopAuthenticationSubmissionTests.swift b/TFMTests/DesktopAuthenticationSubmissionTests.swift index efad8c41..47a98511 100644 --- a/TFMTests/DesktopAuthenticationSubmissionTests.swift +++ b/TFMTests/DesktopAuthenticationSubmissionTests.swift @@ -100,6 +100,38 @@ struct DesktopAuthenticationSubmissionTests { } } + @Test(arguments: [false, true]) + func sftpPassphraseContinuationSavesOnlyWhenRequested(_ save: Bool) throws { + let key = protectedKey() + let secrets = InMemorySecretStore() + let identity = Identity(name: "Deploy", username: "deploy", keyID: key.id) + let host = Host(name: "Server", host: "server.invalid", identityID: identity.id) + let library = HostLibraryModel(hosts: [host], keys: [key], identities: [identity], secrets: secrets) + try secrets.setKeyCredential( + .software(.init(privateKey: "encrypted")), for: key.credentialRef, storage: key.storage) + let app = AppModel(hosts: library) + let channel = SFTPChannelModel(hostID: host.id) + defer { channel.disconnect() } + channel.pendingAuthentication = SSHAuthenticationPrompt( + host: host, selectedIdentityID: identity.id, username: identity.username, + selectedKeyID: key.id, requiresPassphrase: true) + + channel.continueAuthentication( + identityID: identity.id, username: identity.username, password: nil, + passphrase: "saved", keyID: key.id, save: save, using: app.hostLibrary) + + #expect(channel.pendingAuthentication == nil) + guard + case .software(let credential)? = try secrets.keyCredential( + for: key.credentialRef, storage: key.storage) + else { + Issue.record("The protected key must remain available") + return + } + #expect(credential.passphrase == (save ? "saved" : nil)) + #expect(library.host(host.id) == host) + } + private func protectedKey() -> SSHKeyRef { SSHKeyRef(label: "Protected", publicKey: "ssh-ed25519 fixture", hasPassphrase: true) } diff --git a/TFMTests/MultiWindowTests.swift b/TFMTests/MultiWindowTests.swift index 3871cf3f..27042334 100644 --- a/TFMTests/MultiWindowTests.swift +++ b/TFMTests/MultiWindowTests.swift @@ -206,7 +206,7 @@ struct MultiWindowTests { let ssh = try #require(sshWorkspace.focusedPane.sshSession) let sftp = try #require(sftpWorkspace.focusedPane.sftpChannel) ssh.connectIfNeeded(using: app.hostLibrary) - sftp.connectIfNeeded(using: app.transfers) + sftp.connectIfNeeded(using: app.hostLibrary) let sshPrompt = try #require(ssh.pendingAuthentication) for _ in 0..<100 where sftp.pendingAuthentication == nil { try await Task.sleep(for: .milliseconds(10)) @@ -221,7 +221,7 @@ struct MultiWindowTests { destination.open(try #require(source.takeWorkspaceForMove(sshWorkspace.id))) destination.open(try #require(source.takeWorkspaceForMove(sftpWorkspace.id))) ssh.connectIfNeeded(using: app.hostLibrary) - sftp.connectIfNeeded(using: app.transfers) + sftp.connectIfNeeded(using: app.hostLibrary) #expect(ssh.pendingAuthentication?.id == sshPrompt.id) #expect(sftp.pendingAuthentication?.id == sftpPrompt.id) #expect(sftp.path == "/srv") diff --git a/TFMTests/RepositoryOutboxTests.swift b/TFMTests/RepositoryOutboxTests.swift index 356d11e6..b9ed7954 100644 --- a/TFMTests/RepositoryOutboxTests.swift +++ b/TFMTests/RepositoryOutboxTests.swift @@ -197,12 +197,6 @@ struct RepositoryOutboxTests { try context.save() let repository = SwiftDataHostRepository(context: context) - #expect( - try repository.deletionImpact(for: doomed.id) - == HostDeletionImpact( - jumpHostReferences: 1, snippetReferences: 1, forwardingRules: 1 - ) - ) try repository.requestHostDeletion(doomed.id) let hiddenSnapshot = try repository.snapshot() diff --git a/TFMTests/SFTPEditTests.swift b/TFMTests/SFTPEditTests.swift index f34b8b5a..c0589e94 100644 --- a/TFMTests/SFTPEditTests.swift +++ b/TFMTests/SFTPEditTests.swift @@ -282,7 +282,13 @@ struct SFTPEditTests { let client = EditClient() let transfers: SFTPTransferCoordinator let service: SFTPEditService - var connection: SFTPTransferConnection { .borrowed(id: hostID, channel: client) } + private lazy var context = SFTPTransferContext( + snapshot: SSHAdditionalConnectionSnapshot( + configuration: SSHConnectionConfiguration( + hostID: hostID, host: "fixture.invalid", port: 22, + username: "test", credentials: [], expectedHostKeys: [])), + channel: client, primaryRole: .ptyConnectionFallback) + var connection: SFTPTransferConnection { .context(context) } init() { let client = client transfers = SFTPTransferCoordinator(store: HostLibraryModel(), clientFactory: { client }) diff --git a/TFMTests/SFTPFeatureTests.swift b/TFMTests/SFTPFeatureTests.swift index fa0b4bb7..7050f467 100644 --- a/TFMTests/SFTPFeatureTests.swift +++ b/TFMTests/SFTPFeatureTests.swift @@ -642,12 +642,15 @@ struct SFTPFeatureTests { } @Test - func borrowedConnectionRunsSeriallyWithoutReconnect() async { + func contextSerializesPrimaryChannelAndClosesItAfterOwnerRelease() async { let hostID = UUID() - let connectionID = UUID() let store = HostLibraryModel() let gate = SFTPTransferClientGate() - let client = BorrowedSFTPTransferClient(gate: gate) + let client = PrimarySFTPTransferClient(gate: gate) + let context = SFTPTransferContext( + snapshot: SSHAdditionalConnectionSnapshot( + configuration: makeTestConfiguration(hostID: hostID, host: "fixture.invalid")), + channel: client, primaryRole: .ptyConnectionFallback) let coordinator = SFTPTransferCoordinator( store: store, globalLimit: 4, perHostLimit: 4 ) @@ -656,16 +659,15 @@ struct SFTPFeatureTests { for index in 0..<2 { coordinator.enqueue( direction: .download, batchID: UUID(), - localURL: root.appendingPathComponent("borrowed-\(index)"), - remotePath: "/borrowed-\(index)", byteCount: 1, - hostID: hostID, connection: .borrowed(id: connectionID, channel: client), + localURL: root.appendingPathComponent("primary-\(index)"), + remotePath: "/primary-\(index)", byteCount: 1, + hostID: hostID, connection: .context(context), scopedRoot: root, overwrite: true ) } await gate.waitForStarts(1) #expect(await gate.maximumActive == 1) - #expect(coordinator.activeCount == 1) await gate.releaseNext() await gate.waitForStarts(2) #expect(await gate.maximumActive == 1) @@ -675,6 +677,8 @@ struct SFTPFeatureTests { #expect(await client.connectCount == 0) #expect(await client.disconnectCount == 0) #expect(coordinator.sftpTransfers.allSatisfy { $0.state == .completed }) + await context.releaseOwner() + #expect(await client.disconnectCount == 1) } @Test @@ -1390,7 +1394,7 @@ private actor FakeSFTPTransferClient: SFTPClientProtocol { ) async throws { await gate.enter(host: host) } } -private actor BorrowedSFTPTransferClient: SFTPClientProtocol { +private actor PrimarySFTPTransferClient: SFTPClientProtocol { let gate: SFTPTransferClientGate private(set) var connectCount = 0 private(set) var disconnectCount = 0 @@ -1413,12 +1417,12 @@ private actor BorrowedSFTPTransferClient: SFTPClientProtocol { from localURL: URL, to remotePath: String, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { await gate.enter(host: "borrowed") } + ) async throws { await gate.enter(host: "primary") } func downloadFile( from remotePath: String, to localURL: URL, overwrite: Bool, cancellation: TransferCancellationToken, progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { await gate.enter(host: "borrowed") } + ) async throws { await gate.enter(host: "primary") } } private actor ManifestSFTPClient: SFTPClientProtocol { diff --git a/TFMTests/TailscaleSFTPChannelWireTests.swift b/TFMTests/TailscaleSFTPChannelWireTests.swift index 66e71061..fe35dd8c 100644 --- a/TFMTests/TailscaleSFTPChannelWireTests.swift +++ b/TFMTests/TailscaleSFTPChannelWireTests.swift @@ -29,11 +29,10 @@ nonisolated struct TailscaleSFTPChannelWireTests { dnsName: "fixture.test.ts.net", online: true, sshHostKeys: ["fixture"]) ], groupID: group.id) let host = try #require(library.hosts.first) - let transfers = TransferLibraryModel(library: library) - #expect(transfers.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id)) + #expect(library.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id)) let files = SFTPChannelModel(hostID: host.id) defer { files.disconnect() } - files.connect(using: transfers) + files.connect(using: library) for _ in 0..<2_000 { if files.status == .connected || files.pendingAuthentication != nil { break } if case .failed = files.status { break } diff --git a/TFMTests/TailscaleSessionRoutingTests.swift b/TFMTests/TailscaleSessionRoutingTests.swift index c8281f35..fffbbc74 100644 --- a/TFMTests/TailscaleSessionRoutingTests.swift +++ b/TFMTests/TailscaleSessionRoutingTests.swift @@ -13,7 +13,6 @@ struct TailscaleSessionRoutingTests { var group = HostGroup(name: "Tailnet") group.tailscale = TailscaleGroupConfiguration(network: network) let library = HostLibraryModel(groups: [group]) - let transfers = TransferLibraryModel(library: library) let provider = RoutingProvider() library.tailscaleConnections = provider library.replaceTailscalePeers( @@ -31,7 +30,7 @@ struct TailscaleSessionRoutingTests { files?.disconnect() } terminal?.connect(using: library) - files?.connect(using: transfers) + files?.connect(using: library) #expect(terminal?.pendingAuthentication == nil) #expect(files?.pendingAuthentication == nil) for _ in 0..<200 { @@ -47,7 +46,6 @@ struct TailscaleSessionRoutingTests { var group = HostGroup(name: "Tailnet") group.tailscale = TailscaleGroupConfiguration(network: network) let library = HostLibraryModel(groups: [group]) - let transfers = TransferLibraryModel(library: library) let provider = RoutingProvider() library.tailscaleConnections = provider library.replaceTailscalePeers( @@ -64,7 +62,7 @@ struct TailscaleSessionRoutingTests { files?.disconnect() } terminal?.connect(using: library) - files?.connect(using: transfers) + files?.connect(using: library) let prompt = try #require(terminal?.pendingAuthentication ?? files?.pendingAuthentication) #expect(prompt.usernameOnly && prompt.username == "root") terminal?.continueAuthentication( @@ -72,7 +70,7 @@ struct TailscaleSessionRoutingTests { keyID: nil, save: true, using: library) files?.continueAuthentication( identityID: nil, username: "operator", password: nil, passphrase: nil, - keyID: nil, save: true, using: transfers) + keyID: nil, save: true, using: library) for _ in 0..<200 { if await provider.dialer.openedHost != nil { break } try await Task.sleep(for: .milliseconds(10)) diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index be9a973a..8734a263 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -563,7 +563,8 @@ struct WindowStateTests { } @Test - func newChatJoinsHistoryOnlyAfterItsFirstSend() { + func newChatJoinsHistoryOnlyAfterItsFirstSend() async { + let app = AppModel(hosts: HostLibraryModel()) let window = WindowModel() let draft = window.openNewAssistantChat() @@ -571,24 +572,28 @@ struct WindowStateTests { #expect(window.assistantLibrary.conversations.isEmpty) draft.composer.draft = "Hello from a new chat" - let started = window.assistantLibrary.startDraftConversation() + let started = app.sendNewAgentDraft(in: window.assistantLibrary) #expect(started === draft) #expect(window.assistantLibrary.selectedConversation === draft) #expect(window.assistantLibrary.conversations.map(\.id) == [draft.id]) #expect(window.assistantLibrary.draftConversation !== draft) + await started?.stopAndWait() } @Test - func sidebarDraftCreatesConversationOnlyOnFirstValidSend() { + func sidebarDraftCreatesConversationOnlyOnFirstValidSend() async { + let app = AppModel(hosts: HostLibraryModel()) let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example")) let window = WindowModel(workspaces: [workspace]) - #expect(window.sendAssistantSidebarDraft() == nil) + #expect(app.sendNewAgentConversation(window.sidebar.assistantDraft, in: window.assistantLibrary) == nil) #expect(window.assistantLibrary.conversations.isEmpty) window.sidebar.assistantDraft.composer.draft = " Explain this failure. " - let conversation = window.sendAssistantSidebarDraft() + let conversation = app.sendNewAgentConversation( + window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext) + if let conversation { window.assistantSidebarConversationDidStart(conversation) } #expect(conversation?.messages.map(\.text) == ["Explain this failure."]) #expect(window.assistantLibrary.conversations.count == 1) @@ -596,6 +601,7 @@ struct WindowStateTests { #expect(window.sidebarAssistantConversation?.id == conversation?.id) #expect(window.selectedDestination == .workspace(workspace.id)) #expect(window.sidebar.assistantDraft.isEmpty) + await conversation?.stopAndWait() } @Test @@ -604,7 +610,8 @@ struct WindowStateTests { let conversation = ConversationModel() conversation.composer.draft = "Hello" let library = AssistantLibraryModel() - _ = library.startConversation(conversation) + conversation.sendDraft() + library.insertAndSelect(conversation) let window = WindowModel(workspaces: [workspace], assistantLibrary: library) window.openAssistantInSidebar(conversation.id) @@ -708,7 +715,8 @@ struct WindowStateTests { /// conversation promoted out of it arrives in the Assistant destination carrying /// nothing about a host it can no longer see. @Test - func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() { + func sendingFromTheSidebarDoesNotAttachSessionContextToTheConversation() async { + let app = AppModel(hosts: HostLibraryModel()) let host = Host(name: "Server", host: "server.example") let workspace = Workspace.sftpChannel(host: host, initialPath: "/srv/app") workspace.focusedPane.sftpChannel?.homeResolution = .unavailable @@ -718,10 +726,14 @@ struct WindowStateTests { #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"]) - let conversation = window.sendAssistantSidebarDraft() + let conversation = app.sendNewAgentConversation( + window.sidebar.assistantDraft, in: window.assistantLibrary, context: window.sidebar.sessionContext) + if let conversation { window.assistantSidebarConversationDidStart(conversation) } #expect(conversation?.composer.attachments.isEmpty == true) + #expect(conversation?.transcript.first?.contextSnapshot?.sessionContext?.contains("/srv/app") == true) #expect(window.sidebar.sessionContext.chips.map(\.label) == ["Server", "/srv/app"]) + await conversation?.stopAndWait() } /// Leaving every Workspace used to freeze the last host and directory on @@ -745,18 +757,21 @@ struct WindowStateTests { /// the log goes with the message it belongs to rather than sitting in the /// draft as text the user could half-delete. @Test - func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() { + func promotingASidebarDraftSendsItsAttachmentWithTheFirstTurn() async { + let app = AppModel(hosts: HostLibraryModel()) let conversation = ConversationModel() conversation.attach( ContextChip(kind: .output, label: "Connection error", content: "ssh: refused") ) conversation.composer.draft = "Explain this" - _ = AssistantLibraryModel().startConversation(conversation) + #expect(app.sendNewAgentConversation(conversation, in: AssistantLibraryModel()) === conversation) #expect(conversation.messages.first?.text == "Explain this") #expect(conversation.transcript.first?.text.contains("ssh: refused") == true) #expect(conversation.composer.attachments.isEmpty, "the attachment went with the turn") + #expect(conversation.transcript.first?.contextSnapshot != nil) + await conversation.stopAndWait() } @Test @@ -792,7 +807,8 @@ struct WindowStateTests { let conversation = ConversationModel() conversation.composer.draft = "Explain this" let library = AssistantLibraryModel() - _ = library.startConversation(conversation) + conversation.sendDraft() + library.insertAndSelect(conversation) conversation.attach( ContextChip(kind: .output, label: "Connection error", content: "ssh: refused") )