From ad2bf28790989009a7a050f7ecf4f49a44e1b215 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 4 Oct 2026 22:10:09 +0800 Subject: [PATCH] refactor: remove obsolete application paths and adapters Remove the transfer forwarding adapter, borrowed SFTP channel scheduling, legacy assistant send entry points, unreachable Telnet password state, unused Tailscale APIs, and unused host deletion impact projections. Migrate behavioral tests to current paths and cover SFTP passphrase saving. Validation: Swift formatting and shared-copy/localization checks passed. The macOS test run reported failures in ConnectionStatusIconLayoutTests and AgentRuntimeTests; investigation and iOS validation remain pending. --- .../TailscaleIntegrationTests.swift | 9 +- Frameworks/Resources/Localizable.xcstrings | 17 --- .../Agent/AssistantLibraryModel.swift | 23 --- .../Agent/ConversationModel.swift | 4 +- .../Connections/ConnectionJourney.swift | 16 -- .../FeatureModels/TailscaleModel.swift | 16 -- .../TermindApplication/HostRepository.swift | 1 - .../TermindDomain/Hosts/HostDeletion.swift | 12 -- .../InMemoryHostRepository.swift | 7 - .../SwiftDataHostRepository.swift | 11 -- .../ConnectionJourneyRouteView.swift | 1 - .../Transfers/SFTPConcurrencyScheduler.swift | 26 +--- .../Transfers/SFTPTransferCoordinator.swift | 89 +----------- TFITests/MobileAssistantTests.swift | 5 +- TFITests/MobileWorkspaceSessionTests.swift | 21 ++- TFM/App/AppModel.swift | 2 - .../FeatureModels/TransferLibraryModel.swift | 137 ------------------ TFM/Features/SFTP/SFTPBrowserView.swift | 10 +- TFM/Features/Workspaces/WindowModel.swift | 9 -- .../CommandNotificationRenderingTests.swift | 3 +- TFMTests/CommandNotificationTests.swift | 24 ++- ...DesktopAuthenticationSubmissionTests.swift | 32 ++++ TFMTests/MultiWindowTests.swift | 4 +- TFMTests/RepositoryOutboxTests.swift | 6 - TFMTests/SFTPEditTests.swift | 8 +- TFMTests/SFTPFeatureTests.swift | 24 +-- TFMTests/TailscaleSFTPChannelWireTests.swift | 5 +- TFMTests/TailscaleSessionRoutingTests.swift | 8 +- TFMTests/WindowStateTests.swift | 38 +++-- 29 files changed, 146 insertions(+), 422 deletions(-) delete mode 100644 TFM/Application/FeatureModels/TransferLibraryModel.swift 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") )