diff --git a/Sources/App/App.swift b/Sources/App/App.swift index 082fb567..cc71c7cb 100644 --- a/Sources/App/App.swift +++ b/Sources/App/App.swift @@ -109,6 +109,7 @@ struct GhosthubApp: App { ) } WorkspaceSceneBootstrap.ensureBootstrapped() + WorkspaceInventoryStore.shared.startApplicationActivityMonitoring() } var body: some Scene { diff --git a/Sources/App/KwtInventoryClient.swift b/Sources/App/KwtInventoryClient.swift index 8552d755..fbbb39c9 100644 --- a/Sources/App/KwtInventoryClient.swift +++ b/Sources/App/KwtInventoryClient.swift @@ -102,6 +102,19 @@ struct KwtDirectoryWorkspaceRecord: Codable, Equatable, Sendable { } } +extension KwtDirectoryWorkspaceRecord { + init(_ workspace: DirectoryWorkspaceSummary) { + self.init( + name: workspace.name, + path: workspace.path, + sessionName: workspace.tmuxSessionName, + sessionLive: workspace.sessionLive, + tmuxSocketName: workspace.tmuxSocketName, + tmuxAttachMode: workspace.tmuxAttachMode + ) + } +} + struct KwtProjectInventory: Equatable, Sendable { var project: KwtProjectRecord var worktrees: [KwtWorktreeRecord] @@ -166,8 +179,22 @@ struct KwtHostInventory: Equatable, Sendable { } }) } - let exclusions = - excludingWorktrees[item.project.repository] ?? [] + // Removals from a legacy-empty project are keyed by its path. + // A legacy-empty record cannot name its repository, so every + // repository-keyed removal applies to it as well. + var exclusions = excludingWorktrees[ + KwtSnapshotMerger.removalPathKey(item.project.path) + ] ?? [] + if item.project.repository.isEmpty { + for (key, identities) in excludingWorktrees + where !KwtSnapshotMerger.isRemovalPathKey(key) { + exclusions.formUnion(identities) + } + } else { + exclusions.formUnion( + excludingWorktrees[item.project.repository] ?? [] + ) + } retained.worktrees.removeAll { worktree in exclusions.contains { $0.matches( @@ -751,16 +778,7 @@ enum KwtSnapshotMerger { ) let directoryRecords = inventory.directoryWorkspaceWarning != nil && inventory.directoryWorkspaces.isEmpty - ? existingDirectoryWorkspaces.map { - KwtDirectoryWorkspaceRecord( - name: $0.name, - path: $0.path, - sessionName: $0.tmuxSessionName, - sessionLive: $0.sessionLive, - tmuxSocketName: $0.tmuxSocketName, - tmuxAttachMode: $0.tmuxAttachMode - ) - } + ? existingDirectoryWorkspaces.map(KwtDirectoryWorkspaceRecord.init) : inventory.directoryWorkspaces let directoryWorkspaces = directoryRecords.map { record in let recordPath = normalizePath(record.path) @@ -915,7 +933,32 @@ enum KwtSnapshotMerger { return updated } - private static func normalizedPath(_ path: String) -> String { + /// The key worktree removal tombstones live under: the project's + /// normalized path when known, since a worktree belongs to exactly one + /// registration, else the repository identity. + static func removalTombstoneKey( + repository: String, + path: String? + ) -> String { + guard let path else { return repository } + return removalPathKey(path) + } + + /// Path keys carry an explicit marker so no repository identity, on any + /// platform's path syntax, can be mistaken for one. + static func removalPathKey(_ path: String) -> String { + removalPathKeyMarker + normalizedPath(path) + } + + static func isRemovalPathKey(_ key: String) -> Bool { + key.hasPrefix(removalPathKeyMarker) + } + + private static let removalPathKeyMarker = "path\u{0}" + + /// Lexically normalizes a host path so equivalent spellings compare + /// equal without touching any filesystem. + static func normalizedPath(_ path: String) -> String { guard path.contains("/") else { return path } let isAbsolute = path.hasPrefix("/") var components: [Substring] = [] diff --git a/Sources/App/WorkspaceInventoryStore.swift b/Sources/App/WorkspaceInventoryStore.swift new file mode 100644 index 00000000..802bb042 --- /dev/null +++ b/Sources/App/WorkspaceInventoryStore.swift @@ -0,0 +1,996 @@ +@preconcurrency import Combine +import AppKit +import Foundation +import GhosthubSettings +import GhosthubTransport + +@MainActor +final class WorkspaceInventoryStore { + static let shared = WorkspaceInventoryStore() + + typealias KwtLoader = @Sendable ( + CommandHost + ) async throws -> KwtHostInventory + typealias KwtProvisioner = @Sendable (SSHHost) async throws -> Void + typealias TmuxLoader = @Sendable ( + CommandHost + ) async -> Result<[DiscoveredTmuxSession], TmuxBinaryError> + typealias Sleep = @Sendable (Duration) async throws -> Void + + struct HostRegistration: Equatable, Sendable { + let hostID: UUID + let commandHost: CommandHost + let provisioningHost: SSHHost? + } + + enum KwtLoadState { + case idle + case loading + case loaded + case failed(any Error) + case provisioningFailed + } + + enum TmuxLoadState: Sendable { + case idle + case loading + case loaded + case failed(TmuxBinaryError) + } + + struct KwtEntry { + var inventory: KwtHostInventory? + var inventoryRevision: UInt64 + var observationRevision: UInt64 + var state: KwtLoadState + var isFresh: Bool + + static let empty = KwtEntry( + inventory: nil, + inventoryRevision: 0, + observationRevision: 0, + state: .idle, + isFresh: false + ) + } + + struct TmuxEntry: Sendable { + var sessions: [DiscoveredTmuxSession]? + var inventoryRevision: UInt64 + var observationRevision: UInt64 + var state: TmuxLoadState + var isFresh: Bool + + static let empty = TmuxEntry( + sessions: nil, + inventoryRevision: 0, + observationRevision: 0, + state: .idle, + isFresh: false + ) + } + + struct Snapshot { + var kwtByHost: [CommandHost: KwtEntry] = [:] + var tmuxByHost: [CommandHost: TmuxEntry] = [:] + } + + /// A removed project. It matches only the registration that was removed: + /// the same repository at the same path with the same registration + /// fingerprint, so a repository registered again elsewhere is a new + /// project. When either identity is legacy-empty, only the normalized + /// path recorded at removal time identifies the project. + struct ProjectRemovalTombstone: Hashable, Sendable { + let repository: String + let path: String? + let registrationFingerprint: String + + init( + repository: String, + path: String?, + registrationFingerprint: String = "" + ) { + self.repository = repository + self.path = path.map(KwtSnapshotMerger.normalizedPath) + self.registrationFingerprint = registrationFingerprint + } + + func matches(_ record: KwtProjectRecord) -> Bool { + let samePath = path.map { + KwtSnapshotMerger.normalizedPath(record.path) == $0 + } + if !repository.isEmpty, !record.repository.isEmpty { + let sameFingerprint = registrationFingerprint.isEmpty + || record.registrationFingerprint.isEmpty + || record.registrationFingerprint == registrationFingerprint + return record.repository == repository + && samePath != false + && sameFingerprint + } + return samePath == true + } + } + + /// Identifies the mutation behind an authoritative KWT publication. The + /// epoch is captured right after the mutation scope is acquired, so a + /// result that predates a later mutation on the same host is rejected. + struct MutationPublication: Equatable, Sendable { + let hostID: UUID + let host: CommandHost + let epoch: UInt64 + } + + /// Publishes after each change, so a subscriber that mutates the store + /// while reacting sees its own change persist rather than be overwritten + /// by the assignment that triggered the publication. + private(set) var snapshot = Snapshot() { + didSet { snapshotSubject.send(snapshot) } + } + + private let snapshotSubject = CurrentValueSubject( + Snapshot() + ) + + var snapshotPublisher: AnyPublisher { + snapshotSubject.eraseToAnyPublisher() + } + + private struct Subscriber { + var registrations: [HostRegistration] + var wantsKwt: Bool + var wantsTmux: Bool + } + + private let refreshInterval: Duration + private let kwtLoader: KwtLoader + private let kwtProvisioner: KwtProvisioner + private let tmuxLoader: TmuxLoader + private let sleep: Sleep + private let mutationCoordinator: WorktreeMutationCoordinator + private var mutationCancellable: AnyCancellable? + private var appDidBecomeActiveCancellable: AnyCancellable? + private var appDidResignActiveCancellable: AnyCancellable? + private var subscribers: [UUID: Subscriber] = [:] + private var mutationHosts: [WorktreeMutationCoordinator.Scope: Set] = [:] + private var kwtTasks: [CommandHost: Task] = [:] + private var tmuxTasks: [CommandHost: Task] = [:] + private var kwtGenerations: [CommandHost: UInt64] = [:] + private var tmuxGenerations: [CommandHost: UInt64] = [:] + private var fenceGenerationsByHostID: [UUID: UInt64] = [:] + private var kwtMutationEpochsByHost: [CommandHost: UInt64] = [:] + private var satisfiedFenceGenerationsByHostID: [UUID: UInt64] = [:] + private var kwtRemovalTombstonesByHost: + [CommandHost: [String: Set]] = [:] + private var kwtProjectRemovalTombstonesByHost: + [CommandHost: Set] = [:] + private var revision: UInt64 = 0 + private var isApplicationActive = true + private var cadenceTask: Task? + + init( + refreshInterval: Duration = .seconds(30), + kwtLoader: @escaping KwtLoader = { + try await KwtInventoryService().load(from: $0) + }, + kwtProvisioner: @escaping KwtProvisioner = { + try await KwtRemoteProvisioningCoordinator.shared + .ensureInstalled(on: $0) + }, + tmuxLoader: @escaping TmuxLoader = { + await WorkspaceInventoryStore.discoverTmux(on: $0) + }, + sleep: @escaping Sleep = { + try await Task.sleep(for: $0) + }, + mutationCoordinator: WorktreeMutationCoordinator = .shared + ) { + self.refreshInterval = refreshInterval + self.kwtLoader = kwtLoader + self.kwtProvisioner = kwtProvisioner + self.tmuxLoader = tmuxLoader + self.sleep = sleep + self.mutationCoordinator = mutationCoordinator + mutationCancellable = mutationCoordinator.events.sink { + [weak self] event in + self?.mutationEvent(event) + } + } + + func updateSubscriber( + id: UUID, + registrations: [HostRegistration], + wantsKwt: Bool, + wantsTmux: Bool + ) { + let previous = subscribers[id] + let previousKwtHosts = subscribedKwtHosts() + let previousTmuxHosts = subscribedTmuxHosts() + subscribers[id] = Subscriber( + registrations: registrations, + wantsKwt: wantsKwt, + wantsTmux: wantsTmux + ) + // A scene can subscribe after a mutation has already begun. + for scope in mutationCoordinator.scopes where mutationHosts[scope]?.isEmpty != false { + mutationHosts[scope] = commandHosts(for: scope) + } + let currentKwtHosts = subscribedKwtHosts() + let currentTmuxHosts = subscribedTmuxHosts() + invalidateKwtHosts(previousKwtHosts.subtracting(currentKwtHosts)) + invalidateTmuxHosts(previousTmuxHosts.subtracting(currentTmuxHosts)) + + // Scenes re-register on every snapshot change. Only a host or lane + // this subscriber did not have before earns an initial load; a stale + // entry otherwise waits for an explicit refresh or the cadence. + let hosts = Set(registrations.map(\.commandHost)) + func isNew(_ host: CommandHost, wanted: Bool) -> Bool { + guard let previous, wanted else { return true } + return !previous.registrations.contains { $0.commandHost == host } + } + if isApplicationActive, wantsKwt { + for host in hosts + where isNew(host, wanted: previous?.wantsKwt ?? false) + && needsInitialKwtLoad(host) { + requestKwt(host) + } + } + if isApplicationActive, wantsTmux { + for host in hosts + where isNew(host, wanted: previous?.wantsTmux ?? false) + && needsInitialTmuxLoad(host) { + requestTmux(host) + } + } + reconcileCadence() + } + + func removeSubscriber(id: UUID) { + let previousKwtHosts = subscribedKwtHosts() + let previousTmuxHosts = subscribedTmuxHosts() + subscribers.removeValue(forKey: id) + invalidateKwtHosts( + previousKwtHosts.subtracting(subscribedKwtHosts()) + ) + invalidateTmuxHosts( + previousTmuxHosts.subtracting(subscribedTmuxHosts()) + ) + reconcileCadence() + } + + func refreshKwt(for subscriberID: UUID) { + guard let subscriber = subscribers[subscriberID], + subscriber.wantsKwt else { return } + for host in Set(subscriber.registrations.map(\.commandHost)) { + invalidateKwtHosts([host]) + requestKwt(host) + } + } + + func refreshTmux(for subscriberID: UUID) { + guard let subscriber = subscribers[subscriberID], + subscriber.wantsTmux else { return } + for host in Set(subscriber.registrations.map(\.commandHost)) { + invalidateTmuxHosts([host]) + requestTmux(host) + } + } + + func refreshAll(for subscriberID: UUID) { + refreshKwt(for: subscriberID) + refreshTmux(for: subscriberID) + } + + func kwtMutationEpoch(on host: CommandHost) -> UInt64 { + kwtMutationEpochsByHost[host, default: 0] + } + + /// Removal tombstones still active for a host. Scenes apply them to + /// inventory they load themselves so a raw result cannot bring back a + /// removed row before a fresh shared load confirms it is gone. + func removalTombstones( + on host: CommandHost + ) -> [String: Set] { + kwtRemovalTombstonesByHost[host] ?? [:] + } + + func projectRemovalTombstones( + on host: CommandHost + ) -> Set { + kwtProjectRemovalTombstonesByHost[host] ?? [] + } + + func publishKwtInventory( + _ inventory: KwtHostInventory, + on host: CommandHost, + excludingWorktrees: [String: Set] = [:], + mutation: MutationPublication?, + recordsSuccessfulLoad: Bool = true + ) { + if let mutation, + mutation.host != host + || kwtMutationEpochsByHost[host, default: 0] != mutation.epoch { + return + } + kwtGenerations[host, default: 0] &+= 1 + kwtTasks.removeValue(forKey: host)?.cancel() + if recordsSuccessfulLoad, + Self.isAuthoritative(inventory), + let mutation, + isSoleActiveMutation( + hostID: mutation.hostID, + on: host + ) { + satisfiedFenceGenerationsByHostID[mutation.hostID] = + fenceGenerationsByHostID[mutation.hostID, default: 0] + } + recordKwtSuccess( + inventory, + host: host, + excludingWorktrees: excludingWorktrees, + recordsSuccessfulLoad: recordsSuccessfulLoad + ) + } + + /// The current tmux refresh epoch for a host. A scene-local probe captures + /// it before discovery and passes it back to `publishTmuxSessions`, which + /// drops the publication when a newer shared refresh has started since. + func tmuxRefreshEpoch(on host: CommandHost) -> UInt64 { + tmuxGenerations[host, default: 0] + } + + func publishTmuxSessions( + _ sessions: [DiscoveredTmuxSession], + on host: CommandHost, + epoch: UInt64 + ) { + guard tmuxGenerations[host, default: 0] == epoch else { return } + tmuxGenerations[host, default: 0] &+= 1 + tmuxTasks.removeValue(forKey: host)?.cancel() + recordTmuxSuccess(sessions, host: host) + } + + func setApplicationActive(_ isActive: Bool) { + guard isApplicationActive != isActive else { return } + isApplicationActive = isActive + cadenceTask?.cancel() + cadenceTask = nil + guard isActive else { return } + let kwtHosts = subscribedKwtHosts() + let tmuxHosts = subscribedTmuxHosts() + invalidateKwtHosts(kwtHosts) + invalidateTmuxHosts(tmuxHosts) + for host in kwtHosts { + requestKwt(host) + } + for host in tmuxHosts { + requestTmux(host) + } + reconcileCadence() + } + + func startApplicationActivityMonitoring( + center: NotificationCenter = .default, + initialIsActive: Bool = NSApplication.shared.isActive + ) { + guard appDidBecomeActiveCancellable == nil, + appDidResignActiveCancellable == nil else { return } + setApplicationActive(initialIsActive) + appDidBecomeActiveCancellable = center.publisher( + for: NSApplication.didBecomeActiveNotification + ).sink { [weak self] _ in + self?.setApplicationActive(true) + } + appDidResignActiveCancellable = center.publisher( + for: NSApplication.didResignActiveNotification + ).sink { [weak self] _ in + self?.setApplicationActive(false) + } + } + + private func subscribedKwtHosts() -> Set { + Set(subscribers.values.filter(\.wantsKwt).flatMap { + $0.registrations.map(\.commandHost) + }) + } + + private func subscribedTmuxHosts() -> Set { + Set(subscribers.values.filter(\.wantsTmux).flatMap { + $0.registrations.map(\.commandHost) + }) + } + + private func needsInitialKwtLoad(_ host: CommandHost) -> Bool { + let hostIDs = Set(registrations(for: host).map(\.hostID)) + if mutationCoordinator.quarantinedProjectRemovals.keys.contains( + where: { hostIDs.contains($0.hostID) } + ) { + return true + } + // A stale entry, including provisional rows, still needs a load; + // an in-flight task keeps that request from duplicating. + return !(snapshot.kwtByHost[host]?.isFresh ?? false) + } + + private func needsInitialTmuxLoad(_ host: CommandHost) -> Bool { + guard let entry = snapshot.tmuxByHost[host] else { return true } + if case .idle = entry.state { + return true + } + return false + } + + private func requestKwt(_ host: CommandHost) { + guard kwtTasks[host] == nil, !isKwtFenced(host) else { return } + let generation = kwtGenerations[host, default: 0] + let provisioningHost = provisioningHost(for: host) + var entry = snapshot.kwtByHost[host] ?? .empty + entry.state = .loading + entry.isFresh = false + snapshot.kwtByHost[host] = entry + let loader = kwtLoader + let provisioner = kwtProvisioner + kwtTasks[host] = Task { [weak self] in + if let provisioningHost { + do { + try await Self.runDetached { + try await provisioner(provisioningHost) + } + } catch { + guard let self, !Task.isCancelled, + kwtGenerations[host, default: 0] + == generation else { return } + kwtTasks[host] = nil + recordKwtProvisioningFailure(host: host) + return + } + // Invalidation during provisioning makes the load pointless. + guard !Task.isCancelled, + self?.kwtGenerations[host, default: 0] == generation + else { return } + } + do { + let inventory = try await Self.runDetached { + try await loader(host) + } + guard let self, !Task.isCancelled, + kwtGenerations[host, default: 0] == generation + else { return } + // Recording publishes synchronously, and a subscriber may + // end a mutation in response; clear the task first so that + // fence-end reload can start. + kwtTasks[host] = nil + recordKwtSuccess(inventory, host: host) + } catch is CancellationError { + guard let self, + kwtGenerations[host, default: 0] == generation + else { return } + kwtTasks[host] = nil + } catch { + guard let self, !Task.isCancelled, + kwtGenerations[host, default: 0] == generation + else { return } + kwtTasks[host] = nil + recordKwtFailure(error, host: host) + } + } + } + + private func requestTmux(_ host: CommandHost) { + guard tmuxTasks[host] == nil else { return } + tmuxGenerations[host, default: 0] &+= 1 + let generation = tmuxGenerations[host, default: 0] + var entry = snapshot.tmuxByHost[host] ?? .empty + entry.state = .loading + snapshot.tmuxByHost[host] = entry + let loader = tmuxLoader + tmuxTasks[host] = Task { [weak self] in + let result = await Self.runDetached { + await loader(host) + } + guard let self, !Task.isCancelled, + tmuxGenerations[host, default: 0] == generation + else { return } + tmuxTasks[host] = nil + switch result { + case let .success(sessions): + recordTmuxSuccess(sessions, host: host) + case let .failure(error): + recordTmuxFailure(error, host: host) + } + } + } + + private func recordKwtSuccess( + _ inventory: KwtHostInventory, + host: CommandHost, + excludingWorktrees: [String: Set] = [:], + recordsSuccessfulLoad: Bool = true + ) { + var tombstones = kwtRemovalTombstonesByHost[host] ?? [:] + for (repository, exclusions) in excludingWorktrees { + tombstones[repository, default: []].formUnion(exclusions) + } + if recordsSuccessfulLoad { + tombstones = activeRemovalTombstones( + tombstones, + after: inventory + ) + } + var projectTombstones = kwtProjectRemovalTombstonesByHost[host] ?? [] + if recordsSuccessfulLoad { + let active = activeProjectRemovalTombstones( + projectTombstones, + after: inventory + ) + // Worktree exclusions belong to the removed registration too. + for expired in projectTombstones.subtracting(active) { + tombstones.removeValue(forKey: KwtSnapshotMerger.removalTombstoneKey( + repository: expired.repository, + path: expired.path + )) + } + projectTombstones = active + } + if tombstones.isEmpty { + kwtRemovalTombstonesByHost.removeValue(forKey: host) + } else { + kwtRemovalTombstonesByHost[host] = tombstones + } + if projectTombstones.isEmpty { + kwtProjectRemovalTombstonesByHost.removeValue(forKey: host) + } else { + kwtProjectRemovalTombstonesByHost[host] = projectTombstones + } + revision &+= 1 + var entry = snapshot.kwtByHost[host] ?? .empty + var reconciled = inventory.retainingFailedProjectWorktrees( + from: entry.inventory, + excludingWorktrees: tombstones + ) + reconciled.projects.removeAll { item in + projectTombstones.contains { $0.matches(item.project) } + } + entry.inventory = reconciled + entry.inventoryRevision = revision + if recordsSuccessfulLoad { + entry.observationRevision = revision + entry.state = .loaded + entry.isFresh = true + } else { + // Provisional rows must not read as an authoritative load. + entry.isFresh = false + } + snapshot.kwtByHost[host] = entry + } + + private func activeRemovalTombstones( + _ tombstones: [String: Set], + after inventory: KwtHostInventory + ) -> [String: Set] { + guard inventory.projectsWarning == nil else { return tombstones } + return tombstones.reduce(into: [:]) { active, entry in + // A path key names a legacy-empty project by its path. A + // repository key also matches legacy-empty rows, which may still + // be this project, as the scene does. + let projects = inventory.projects.filter { item in + if KwtSnapshotMerger.isRemovalPathKey(entry.key) { + return KwtSnapshotMerger.removalPathKey(item.project.path) + == entry.key + } + return item.project.repository == entry.key + || item.project.repository.isEmpty + } + guard !projects.isEmpty else { return } + if projects.contains(where: { $0.warning != nil }) { + active[entry.key] = entry.value + return + } + let retained = entry.value.filter { tombstone in + projects.contains { project in + project.worktrees.contains { + tombstone.matches( + path: $0.path, + generation: $0.generation + ) + } + } + } + if !retained.isEmpty { + active[entry.key] = retained + } + } + } + + private func activeProjectRemovalTombstones( + _ tombstones: Set, + after inventory: KwtHostInventory + ) -> Set { + guard inventory.projectsWarning == nil else { return tombstones } + return tombstones.filter { tombstone in + inventory.projects.contains { + // A fresh registration fingerprint supersedes a removal + // whose registration was unknown to the shared cache. + tombstone.matches($0.project) + && (!tombstone.registrationFingerprint.isEmpty + || $0.project.registrationFingerprint.isEmpty) + } + } + } + + private static func isAuthoritative(_ inventory: KwtHostInventory) -> Bool { + inventory.projectsWarning == nil + && inventory.projects.allSatisfy { $0.warning == nil } + } + + private func isSoleActiveMutation( + hostID: UUID, + on commandHost: CommandHost + ) -> Bool { + let activeScopes = fencingScopes.filter { + commandHosts(for: $0).contains(commandHost) + } + return activeScopes.count == 1 + && activeScopes.first?.hostID == hostID + } + + /// Mutation scopes that fence inventory. A quarantined project removal + /// stays registered until inventory resolves it, so it must not block + /// the loads and publications that resolution depends on. + private var fencingScopes: Set { + mutationCoordinator.scopes.subtracting( + mutationCoordinator.quarantinedProjectRemovals.keys + ) + } + + private func recordKwtFailure( + _ error: any Error, + host: CommandHost + ) { + revision &+= 1 + var entry = snapshot.kwtByHost[host] ?? .empty + entry.observationRevision = revision + entry.state = .failed(error) + entry.isFresh = false + snapshot.kwtByHost[host] = entry + } + + private func recordKwtProvisioningFailure(host: CommandHost) { + revision &+= 1 + var entry = snapshot.kwtByHost[host] ?? .empty + entry.observationRevision = revision + entry.state = .provisioningFailed + entry.isFresh = false + snapshot.kwtByHost[host] = entry + } + + private func recordTmuxSuccess( + _ sessions: [DiscoveredTmuxSession], + host: CommandHost + ) { + revision &+= 1 + var entry = snapshot.tmuxByHost[host] ?? .empty + entry.sessions = sessions + entry.inventoryRevision = revision + entry.observationRevision = revision + entry.state = .loaded + entry.isFresh = true + snapshot.tmuxByHost[host] = entry + } + + private func recordTmuxFailure( + _ error: TmuxBinaryError, + host: CommandHost + ) { + revision &+= 1 + var entry = snapshot.tmuxByHost[host] ?? .empty + entry.observationRevision = revision + entry.state = .failed(error) + entry.isFresh = false + snapshot.tmuxByHost[host] = entry + } + + private func invalidateKwtHosts(_ hosts: Set) { + for host in hosts { + kwtGenerations[host, default: 0] &+= 1 + kwtTasks.removeValue(forKey: host)?.cancel() + if var entry = snapshot.kwtByHost[host] { + entry.state = .idle + entry.isFresh = false + snapshot.kwtByHost[host] = entry + } + } + } + + private func invalidateTmuxHosts(_ hosts: Set) { + for host in hosts { + tmuxGenerations[host, default: 0] &+= 1 + tmuxTasks.removeValue(forKey: host)?.cancel() + if var entry = snapshot.tmuxByHost[host] { + entry.state = .idle + entry.isFresh = false + snapshot.tmuxByHost[host] = entry + } + } + } + + private func registrations( + for host: CommandHost + ) -> [HostRegistration] { + subscribers.values.flatMap(\.registrations).filter { + $0.commandHost == host + } + } + + private func provisioningHost(for host: CommandHost) -> SSHHost? { + registrations(for: host) + .compactMap(\.provisioningHost) + .filter { $0.platform == .macOS || $0.platform == .linux } + .sorted { $0.configKey < $1.configKey } + .first + } + + private func isKwtFenced(_ host: CommandHost) -> Bool { + fencingScopes.contains { commandHosts(for: $0).contains(host) } + } + + private func commandHosts(for scope: WorktreeMutationCoordinator.Scope) -> Set { + if let hosts = mutationHosts[scope], !hosts.isEmpty { + return hosts + } + return Set(subscribers.values.flatMap(\.registrations) + .filter { $0.hostID == scope.hostID } + .map(\.commandHost)) + } + + private func mutationEvent( + _ event: WorktreeMutationCoordinator.Event + ) { + switch event.phase { + case .began: + fenceGenerationsByHostID[event.scope.hostID, default: 0] &+= 1 + let hosts = commandHosts(for: event.scope) + mutationHosts[event.scope] = hosts + for host in hosts { + kwtMutationEpochsByHost[host, default: 0] &+= 1 + } + invalidateKwtHosts(hosts) + case .ended: + let hosts = commandHosts(for: event.scope) + mutationHosts.removeValue(forKey: event.scope) + let tmuxHosts = hosts.intersection(subscribedTmuxHosts()) + invalidateTmuxHosts(tmuxHosts) + for host in tmuxHosts { + requestTmux(host) + } + let generation = fenceGenerationsByHostID[ + event.scope.hostID, + default: 0 + ] + let fenceIsSatisfied = satisfiedFenceGenerationsByHostID[ + event.scope.hostID + ] == generation + if !fenceIsSatisfied { + for host in hosts { + if !event.removalTombstones.isEmpty { + let key = KwtSnapshotMerger.removalTombstoneKey( + repository: event.scope.projectIdentity, + path: event.projectPath + ) + kwtRemovalTombstonesByHost[host, default: [:]][ + key, + default: [] + ].formUnion(event.removalTombstones) + } + if event.removesProject { + let cached = cachedProjectRecord( + repository: event.scope.projectIdentity, + path: event.projectPath, + on: host + ) + kwtProjectRemovalTombstonesByHost[host, default: []] + .insert(ProjectRemovalTombstone( + repository: event.scope.projectIdentity, + path: event.projectPath ?? cached?.path, + registrationFingerprint: + cached?.registrationFingerprint ?? "" + )) + } + applyRemovalTombstonesToCachedInventory(on: host) + } + } + guard !fenceIsSatisfied else { return } + for host in hosts where subscribedKwtHosts().contains(host) { + requestKwt(host) + } + case .quarantined: + let hosts = commandHosts(for: event.scope) + invalidateKwtHosts(hosts) + for host in hosts where subscribedKwtHosts().contains(host) { + requestKwt(host) + } + case .registered: + let hosts = Set(subscribers.values.flatMap(\.registrations) + .filter { $0.hostID == event.scope.hostID } + .map(\.commandHost)) + for host in hosts { + noteProjectRegistration( + on: host, + projectIdentity: event.scope.projectIdentity, + projectPath: event.projectPath + ) + } + case .willRemove: + break + } + } + + /// Also accepts unsaved host drafts that have no scene inventory identity. + func noteProjectRegistration( + on host: CommandHost, + projectIdentity: String, + projectPath: String? + ) { + // Registration is not fenced, so a concurrent mutation's result + // or an in-flight load may predate it. Advancing the epoch + // rejects such a result, invalidation discards such a load, and + // dropping the satisfied fence makes the mutation end reload. + for registration in registrations(for: host) { + satisfiedFenceGenerationsByHostID.removeValue(forKey: registration.hostID) + } + kwtMutationEpochsByHost[host, default: 0] &+= 1 + clearRemovalTombstones( + forRepository: projectIdentity, + path: projectPath, + on: host + ) + invalidateKwtHosts([host]) + if subscribedKwtHosts().contains(host) { + requestKwt(host) + } + } + + private func cachedProjectRecord( + repository: String, + path: String?, + on host: CommandHost + ) -> KwtProjectRecord? { + guard !repository.isEmpty else { return nil } + let path = path.map(KwtSnapshotMerger.normalizedPath) + return snapshot.kwtByHost[host]?.inventory?.projects.first { + $0.project.repository == repository + && (path == nil + || KwtSnapshotMerger.normalizedPath($0.project.path) + == path) + }?.project + } + + private func clearRemovalTombstones( + forRepository repository: String, + path: String?, + on host: CommandHost + ) { + // Only the registered project's own tombstones are released: those + // recorded at its path, plus repository-keyed ones whose path is + // unknown. The same repository registered elsewhere keeps its fence. + let path = path.map(KwtSnapshotMerger.normalizedPath) + let projectTombstones = kwtProjectRemovalTombstonesByHost[host] ?? [] + let cleared = projectTombstones.filter { tombstone in + if path != nil, tombstone.path == path { + return true + } + guard !repository.isEmpty, tombstone.repository == repository + else { return false } + return tombstone.path == nil || path == nil + } + kwtProjectRemovalTombstonesByHost[host]?.subtract(cleared) + if kwtProjectRemovalTombstonesByHost[host]?.isEmpty == true { + kwtProjectRemovalTombstonesByHost.removeValue(forKey: host) + } + var keys: Set = [] + for tombstone in cleared { + if let tombstonePath = tombstone.path { + keys.insert(KwtSnapshotMerger.removalPathKey(tombstonePath)) + } + } + if let path { + keys.insert(KwtSnapshotMerger.removalPathKey(path)) + } + if !repository.isEmpty { + keys.insert(repository) + } + for key in keys { + kwtRemovalTombstonesByHost[host]?.removeValue(forKey: key) + } + if kwtRemovalTombstonesByHost[host]?.isEmpty == true { + kwtRemovalTombstonesByHost.removeValue(forKey: host) + } + } + + private func applyRemovalTombstonesToCachedInventory( + on host: CommandHost + ) { + guard var entry = snapshot.kwtByHost[host], + let inventory = entry.inventory + else { return } + var filtered = inventory.retainingFailedProjectWorktrees( + from: inventory, + excludingWorktrees: kwtRemovalTombstonesByHost[host] ?? [:] + ) + let removedProjects = kwtProjectRemovalTombstonesByHost[host] ?? [] + filtered.projects.removeAll { item in + removedProjects.contains { $0.matches(item.project) } + } + guard filtered != inventory else { return } + revision &+= 1 + entry.inventory = filtered + entry.inventoryRevision = revision + snapshot.kwtByHost[host] = entry + } + + private func requestSubscribedInventory() { + for host in subscribedKwtHosts() { + requestKwt(host) + } + for host in subscribedTmuxHosts() { + requestTmux(host) + } + } + + private func reconcileCadence() { + guard isApplicationActive, !subscribers.isEmpty else { + cadenceTask?.cancel() + cadenceTask = nil + return + } + guard cadenceTask == nil else { return } + let interval = refreshInterval + let sleep = sleep + cadenceTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await sleep(interval) + } catch { + return + } + guard let self, isApplicationActive else { return } + requestSubscribedInventory() + } + } + } + + private nonisolated static func runDetached( + _ operation: @escaping @Sendable () async -> Value + ) async -> Value { + let task = Task.detached(priority: .utility, operation: operation) + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + } + + private nonisolated static func runDetached( + _ operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + let task = Task.detached(priority: .utility, operation: operation) + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } + + private static func discoverTmux( + on host: CommandHost + ) async -> Result<[DiscoveredTmuxSession], TmuxBinaryError> { + let resolver = TmuxBinaryResolver() + return switch host { + case .local: + await Task.detached(priority: .utility) { + resolver.discoverSessions() + }.value + case let .ssh(info): + await resolver.discoverSessions(on: info) + } + } +} diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index d2926589..9643418f 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -43,12 +43,6 @@ private func presentGhosthubAlert( @MainActor final class WorkspaceSceneModel: ObservableObject { - private enum KwtInventoryRefreshOutcome: Sendable { - case loaded(KwtHostInventory) - case provisioningFailed - case inventoryFailed(any Error) - } - nonisolated static func runReconnectValidationProbe( _ operation: @escaping @Sendable () -> Value ) async -> Value { @@ -177,11 +171,10 @@ final class WorkspaceSceneModel: ObservableObject { private var tmuxDiscoveryFailuresByHost: [UUID: String] = [:] private var tmuxFreshHostIDs: Set = [] private var isTmuxDiscoveryLoading = false - private var inventoryRefreshProgress = WorkspaceInventoryRefreshProgress() - private var tmuxDiscoveryGeneration = 0 + private(set) var inventoryRefreshProgress = + WorkspaceInventoryRefreshProgress() private var tmuxDiscoveryObservationSequence: UInt64 = 0 private var latestTmuxDiscoveryObservationByHost: [UUID: UInt64] = [:] - private var tmuxDiscoveryTask: Task? private var herdrDiscoveryEnabled = false private var herdrSessionsByHost: [UUID: [HerdrSessionSummary]] = [:] private var herdrAvailabilityByHost: [UUID: Bool] = [:] @@ -221,12 +214,26 @@ final class WorkspaceSceneModel: ObservableObject { @Published private(set) var workspaceInventoryWarningsByHost: [UUID: String] = [:] private var kwtInventoryEnabled = false - private var kwtInventoryGeneration = 0 - private var kwtInventoryTask: Task? private var kwtInventoriesByHost: [UUID: KwtHostInventory] = [:] private var kwtAvailabilityByHost: [UUID: Bool] = [:] private var kwtInventoryFailuresByHost: [UUID: String] = [:] private var isKwtInventoryLoading = false + private struct SharedInventoryApplicationKey: Hashable { + let hostID: UUID + let commandHost: CommandHost + } + private let workspaceInventoryStore: WorkspaceInventoryStore + private let workspaceInventorySubscriberID = UUID() + private var workspaceInventoryCancellable: AnyCancellable? + private var appliedKwtInventoryRevisions: + [SharedInventoryApplicationKey: UInt64] = [:] + private var appliedKwtObservationRevisions: + [SharedInventoryApplicationKey: UInt64] = [:] + private var appliedTmuxInventoryRevisions: + [SharedInventoryApplicationKey: UInt64] = [:] + private var appliedTmuxObservationRevisions: + [SharedInventoryApplicationKey: UInt64] = [:] + private var isConsumingSharedInventory = false private var ownsWorktreeMutation = false private let worktreeMutationCoordinator: WorktreeMutationCoordinator private let worktreeMutationParticipantID = UUID() @@ -253,11 +260,6 @@ final class WorkspaceSceneModel: ObservableObject { [UUID: HerdrLifecycleAuthority] = [:] private var fencedWorktreeMutationScopes: Set = [] - private var worktreeRemovalTombstones: - [ - WorktreeMutationCoordinator.Scope: - Set - ] = [:] var isWorkspaceInventoryRefreshComplete: Bool { inventoryRefreshProgress.kwtCompleted @@ -473,6 +475,7 @@ final class WorkspaceSceneModel: ObservableObject { var outcome: TmuxSessionProbeOutcome var discovery: ( sequence: UInt64, + epoch: UInt64?, result: Result<[DiscoveredTmuxSession], TmuxBinaryError> )? } @@ -1097,6 +1100,7 @@ final class WorkspaceSceneModel: ObservableObject { } }, worktreeMutationCoordinator: WorktreeMutationCoordinator = .shared, + workspaceInventoryStore: WorkspaceInventoryStore = .shared, herdrLifecycleCoordinator: HerdrSessionLifecycleCoordinator = .shared, zellijSessionKillCoordinator: ZellijSessionKillCoordinator = .shared, herdrSessionRecordReader: @@ -1378,6 +1382,7 @@ final class WorkspaceSceneModel: ObservableObject { ) self.workspaceConfiguration = workspaceConfiguration self.worktreeMutationCoordinator = worktreeMutationCoordinator + self.workspaceInventoryStore = workspaceInventoryStore self.herdrLifecycleCoordinator = herdrLifecycleCoordinator self.zellijSessionKillCoordinator = zellijSessionKillCoordinator self.herdrSessionRecordReader = herdrSessionRecordReader @@ -1786,6 +1791,11 @@ final class WorkspaceSceneModel: ObservableObject { [weak self] event in self?.worktreeMutationEvent(event) } + workspaceInventoryCancellable = workspaceInventoryStore + .snapshotPublisher + .sink { [weak self] snapshot in + self?.consumeSharedInventory(snapshot) + } publishProtectedTmuxEndpoints() herdrLifecycleCancellable = herdrLifecycleCoordinator.events.sink { [weak self] event in @@ -1871,6 +1881,14 @@ final class WorkspaceSceneModel: ObservableObject { configuredExeHostsCancellable?.cancel() terminalColorsCancellable?.cancel() sessionPreviewModeCancellable?.cancel() + workspaceInventoryCancellable?.cancel() + let workspaceInventoryStore = workspaceInventoryStore + let workspaceInventorySubscriberID = workspaceInventorySubscriberID + Task { @MainActor in + workspaceInventoryStore.removeSubscriber( + id: workspaceInventorySubscriberID + ) + } worktreeMutationCancellable?.cancel() let mutationCoordinator = worktreeMutationCoordinator let mutationParticipantID = worktreeMutationParticipantID @@ -1881,8 +1899,6 @@ final class WorkspaceSceneModel: ObservableObject { } herdrLifecycleCancellable?.cancel() zellijSessionKillCancellable?.cancel() - kwtInventoryTask?.cancel() - tmuxDiscoveryTask?.cancel() herdrDiscoveryTask?.cancel() herdrShortcutNavigationTask?.cancel() zellijDiscoveryTask?.cancel() @@ -2455,6 +2471,10 @@ final class WorkspaceSceneModel: ObservableObject { cancelAllPresentationSSHAcquisitions() configuredSSHHostsCancellable?.cancel() configuredExeHostsCancellable?.cancel() + workspaceInventoryCancellable?.cancel() + workspaceInventoryStore.removeSubscriber( + id: workspaceInventorySubscriberID + ) worktreeMutationCancellable?.cancel() worktreeMutationCoordinator.retireProtectedEndpoints( for: worktreeMutationParticipantID @@ -2492,8 +2512,6 @@ final class WorkspaceSceneModel: ObservableObject { herdrLifecycleAuthorities.removeAll() herdrLifecycleCancellable?.cancel() zellijSessionKillCancellable?.cancel() - kwtInventoryTask?.cancel() - tmuxDiscoveryTask?.cancel() herdrDiscoveryTask?.cancel() zellijDiscoveryTask?.cancel() zellijCreationDiscoveryRetryTask?.cancel() @@ -2546,8 +2564,9 @@ final class WorkspaceSceneModel: ObservableObject { /// Refreshes the sidebar directly from each host's kwt and tmux inventory. func refreshKwtInventory() { - scheduleKwtInventory() - scheduleTmuxSessionDiscovery() + workspaceInventoryStore.refreshAll( + for: workspaceInventorySubscriberID + ) refreshHerdrSessionDiscovery() refreshZellijSessionDiscovery() } @@ -2555,11 +2574,8 @@ final class WorkspaceSceneModel: ObservableObject { func startKwtInventory() { guard !kwtInventoryEnabled else { return } kwtInventoryEnabled = true - let generation = kwtInventoryGeneration reconcileInventoryHosts() - if generation == kwtInventoryGeneration { - scheduleKwtInventory() - } + updateSharedInventorySubscription() } func createWorktree(_ request: WorktreeCreateRequest) async throws { @@ -2587,10 +2603,8 @@ final class WorkspaceSceneModel: ObservableObject { ownsWorktreeMutation = false throw KwtWorktreeError.creationInProgress } - // The scene-wide refresh is cancelled so it cannot race the mutation, - // and only the mutated host is reloaded inline. Every exit therefore - // owes the remaining hosts a fresh sweep. invalidateKwtInventoryRefresh() + let mutation = kwtMutationPublication(hostID: mutationHostID) defer { ownsWorktreeMutation = false worktreeMutationCoordinator.release( @@ -2617,9 +2631,9 @@ final class WorkspaceSceneModel: ObservableObject { let refreshed = try await kwtInventoryLoader(current.host) applyAuthoritativeKwtInventory( refreshed, - hostID: current.project.hostID + hostID: current.project.hostID, + mutation: mutation ) - scheduleTmuxSessionDiscovery() } catch { recordKwtUnavailability(error, hostID: project.hostID) throw error @@ -2823,6 +2837,7 @@ final class WorkspaceSceneModel: ObservableObject { var killedSessionReestablishmentTarget: WorkspaceTmuxSessionSelection? invalidateKwtInventoryRefresh() + let mutation = kwtMutationPublication(hostID: mutationHostID) defer { ownsWorktreeMutation = false worktreeMutationCoordinator.release( @@ -2831,7 +2846,8 @@ final class WorkspaceSceneModel: ObservableObject { removalTombstones: removalTombstones, reconciledRestorationTargets: reconciledRestorationTargets, requiresWorkspaceReestablishment: - requiresWorkspaceReestablishment + requiresWorkspaceReestablishment, + projectPath: requestedProject.rootPath ) if let killedSessionReestablishmentTarget { _ = presentTmuxSession( @@ -2966,7 +2982,8 @@ final class WorkspaceSceneModel: ObservableObject { request, tombstone: removalTombstone, hostID: project.hostID, - confirmedHost: confirmedHost + confirmedHost: confirmedHost, + mutation: mutation ) reconciledRestorationTargets = outcome.restorationTargets @@ -3029,7 +3046,6 @@ final class WorkspaceSceneModel: ObservableObject { worktree, hostID: project.hostID ) - scheduleTmuxSessionDiscovery() guard !checkoutAlreadyAbsent else { return } do { @@ -3038,13 +3054,17 @@ final class WorkspaceSceneModel: ObservableObject { refreshed, hostID: project.hostID, excludingWorktrees: [ - project.scopedKey: [ + KwtSnapshotMerger.removalTombstoneKey( + repository: project.scopedKey, + path: project.rootPath + ): [ KwtWorktreeIdentity( path: worktree.path, generation: generation ), ], - ] + ], + mutation: mutation ) } catch { recordKwtUnavailability(error, hostID: project.hostID) @@ -3132,7 +3152,8 @@ final class WorkspaceSceneModel: ObservableObject { _ request: WorktreeRemovalRequest, tombstone: WorktreeMutationCoordinator.RemovalTombstone, hostID: UUID, - confirmedHost: CommandHost + confirmedHost: CommandHost, + mutation: WorkspaceInventoryStore.MutationPublication? ) async -> ( identityRemoved: Bool, targetChanged: Bool, @@ -3161,8 +3182,12 @@ final class WorkspaceSceneModel: ObservableObject { refreshed, hostID: hostID, excludingWorktrees: [ - request.project.scopedKey: [tombstone], - ] + KwtSnapshotMerger.removalTombstoneKey( + repository: request.project.scopedKey, + path: request.project.rootPath + ): [tombstone], + ], + mutation: mutation ) return (true, false, nil) } catch KwtWorktreeError.removalTargetChanged { @@ -3267,7 +3292,11 @@ final class WorkspaceSceneModel: ObservableObject { if let repositoryItem, let pathItem, repositoryItem.project.repository != pathItem.project.repository { - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) throw KwtWorktreeError.removalTargetChanged } guard let item = repositoryItem else { @@ -3279,11 +3308,19 @@ final class WorkspaceSceneModel: ObservableObject { message: warning ) } - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) throw KwtWorktreeError.removalTargetChanged } guard item.project.path == request.project.rootPath else { - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) throw KwtWorktreeError.removalTargetChanged } guard let record = item.worktrees.first(where: { @@ -3294,13 +3331,21 @@ final class WorkspaceSceneModel: ObservableObject { hostWorktrees.contains(where: { $0.generation == confirmedGeneration }) { - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) throw KwtWorktreeError.removalTargetChanged } if hostWorktrees.contains(where: { removalTmuxEndpoint(request.worktree, matches: $0) }) { - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) throw KwtWorktreeError.removalTargetChanged } if let warning = inventory.projects.compactMap(\.warning).first { @@ -3311,7 +3356,11 @@ final class WorkspaceSceneModel: ObservableObject { } return nil } - applyAuthoritativeKwtInventory(inventory, hostID: hostID) + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publishToStore: false + ) guard let worktree = snapshot.worktree(id: request.worktree.id), let project = snapshot.project(id: request.project.id), record.repository == request.project.scopedKey, @@ -3576,9 +3625,8 @@ final class WorkspaceSceneModel: ObservableObject { ownsWorktreeMutation = false throw KwtPullRequestError.importInProgress } - // See `createWorktree`: cancelling the scene-wide refresh leaves every - // host but this one stale, including on the success path. invalidateKwtInventoryRefresh() + let mutation = kwtMutationPublication(hostID: mutationHostID) defer { ownsWorktreeMutation = false worktreeMutationCoordinator.release( @@ -3613,12 +3661,16 @@ final class WorkspaceSceneModel: ObservableObject { } kwtAvailabilityByHost[operation.project.hostID] = true + var refreshedSuccessfully = false do { let refreshed = try await kwtInventoryLoader(operation.host) applyAuthoritativeKwtInventory( refreshed, - hostID: operation.project.hostID + hostID: operation.project.hostID, + publish: false, + publishToStore: false ) + refreshedSuccessfully = true } catch { recordKwtUnavailability( error, @@ -3628,18 +3680,22 @@ final class WorkspaceSceneModel: ObservableObject { error.localizedDescription } - mergeImportedWorkspace( + let mergedInventory = mergeImportedWorkspace( operation.result.workspace, project: operation.project ) - applyInventoryOverlayIfNeeded() + applyAuthoritativeKwtInventory( + mergedInventory, + hostID: operation.project.hostID, + mutation: mutation, + recordsSuccessfulLoad: refreshedSuccessfully + ) annotateImportedPullRequest( operation.result.pullRequest, workspace: operation.result.workspace, hostID: operation.project.hostID ) updateWorkspaceInventoryState() - scheduleTmuxSessionDiscovery() guard let importedWorktree = snapshot.worktrees.first(where: { $0.hostID == operation.project.hostID @@ -3662,14 +3718,12 @@ final class WorkspaceSceneModel: ObservableObject { private func mergeImportedWorkspace( _ workspace: PullRequestWorkspace, project: ProjectSummary - ) { - guard var inventory = kwtInventoriesByHost[project.hostID] else { - mergeImportedWorkspaceIntoSnapshot( - workspace, - project: project - ) - return - } + ) -> KwtHostInventory { + var inventory = kwtInventoriesByHost[project.hostID] + ?? inventoryHosts[project.hostID].flatMap { + workspaceInventoryStore.snapshot.kwtByHost[$0]?.inventory + } + ?? kwtInventoryFromSnapshot(hostID: project.hostID) let projectIndex = inventory.projects.firstIndex { $0.project.repository == project.scopedKey || normalizedWorkspacePath($0.project.path) @@ -3720,42 +3774,47 @@ final class WorkspaceSceneModel: ObservableObject { )) } kwtInventoriesByHost[project.hostID] = inventory + return inventory } - private func mergeImportedWorkspaceIntoSnapshot( - _ workspace: PullRequestWorkspace, - project: ProjectSummary - ) { - if let index = snapshot.worktrees.firstIndex(where: { - $0.hostID == project.hostID - && normalizedWorkspacePath($0.path) - == normalizedWorkspacePath(workspace.path) - }) { - snapshot.worktrees[index].branch = workspace.branch - snapshot.worktrees[index].tmuxSessionName = - workspace.sessionName - snapshot.worktrees[index].tmuxSocketName = - workspace.tmuxSocketName - snapshot.worktrees[index].tmuxAttachMode = - workspace.tmuxAttachMode - return + private func kwtInventoryFromSnapshot(hostID: UUID) -> KwtHostInventory { + let projects = snapshot.projects.filter { + $0.hostID == hostID && !$0.isSynthesized + }.map { project in + KwtProjectInventory( + project: KwtProjectRecord( + repository: project.scopedKey, + name: project.name, + path: project.rootPath, + lastTouched: nil, + registrationFingerprint: + project.registrationFingerprint + ), + worktrees: snapshot.worktrees.filter { + $0.hostID == hostID && $0.projectID == project.id + }.map { worktree in + KwtWorktreeRecord( + path: worktree.path, + branch: worktree.branch, + commitHash: "", + isMain: worktree.isPrimary, + createdAt: worktree.createdAt, + generation: worktree.generation, + repository: project.scopedKey, + sessionName: worktree.tmuxSessionName ?? "", + tmuxSocketName: worktree.tmuxSocketName, + tmuxAttachMode: worktree.tmuxAttachMode + ) + }, + warning: nil + ) } - snapshot.worktrees.append(WorktreeSummary( - id: UUID(), - hostID: project.hostID, - projectID: project.id, - scopedKey: workspace.path, - name: workspace.branch, - path: workspace.path, - branch: workspace.branch, - tmuxSessionName: workspace.sessionName, - tmuxSocketName: workspace.tmuxSocketName, - tmuxAttachMode: workspace.tmuxAttachMode, - sessionBackend: - snapshot.host(id: project.hostID)?.kind == .remote - ? .remoteTmux - : .localTmux - )) + return KwtHostInventory( + projects: projects, + directoryWorkspaces: snapshot.directoryWorkspaces + .filter { $0.hostID == hostID } + .map(KwtDirectoryWorkspaceRecord.init) + ) } private func annotateImportedPullRequest( @@ -3789,6 +3848,7 @@ final class WorkspaceSceneModel: ObservableObject { } ) guard resolved != inventoryHosts else { + updateSharedInventorySubscription() applyInventoryOverlayIfNeeded() return } @@ -3868,13 +3928,24 @@ final class WorkspaceSceneModel: ObservableObject { for (hostID, target) in resolved where !Self.supportsZellij(target) { zellijSessionsByHost[hostID] = [] } - worktreeRemovalTombstones = worktreeRemovalTombstones.filter { - retainedHostIDs.contains($0.key.hostID) - } inventoryHosts = resolved + appliedKwtInventoryRevisions = appliedKwtInventoryRevisions.filter { + resolved[$0.key.hostID] == $0.key.commandHost + } + appliedKwtObservationRevisions = + appliedKwtObservationRevisions.filter { + resolved[$0.key.hostID] == $0.key.commandHost + } + appliedTmuxInventoryRevisions = + appliedTmuxInventoryRevisions.filter { + resolved[$0.key.hostID] == $0.key.commandHost + } + appliedTmuxObservationRevisions = + appliedTmuxObservationRevisions.filter { + resolved[$0.key.hostID] == $0.key.commandHost + } + updateSharedInventorySubscription() applyInventoryOverlayIfNeeded() - scheduleKwtInventory() - scheduleTmuxSessionDiscovery() scheduleHerdrSessionDiscovery() scheduleZellijSessionDiscovery() } @@ -3975,152 +4046,229 @@ final class WorkspaceSceneModel: ObservableObject { return [hostID: value] } - private func scheduleKwtInventory() { - guard kwtInventoryEnabled, - !ownsWorktreeMutation else { return } - let fencedHostIDs = Set( - fencedWorktreeMutationScopes.map(\.hostID) - ) - let targets = inventoryHosts.filter { - !fencedHostIDs.contains($0.key) - } + private func updateSharedInventorySubscription() { + guard !isShutDown else { return } let configuredHosts = Dictionary( (configuredSSHHostsProvider() + configuredExeHostsProvider().map(\.sshHost)) .map { ($0.configKey, $0) }, uniquingKeysWith: { first, _ in first } ) - let automaticProvisioningHosts: [UUID: SSHHost] = Dictionary( - uniqueKeysWithValues: targets.compactMap { hostID, target in - guard case .ssh = target, + let registrations = inventoryHosts.map { hostID, commandHost in + let provisioningHost: SSHHost? = { + guard case .ssh = commandHost, let summary = snapshot.host(id: hostID), let host = configuredHosts[summary.configKey], host.platform == .macOS || host.platform == .linux else { return nil } - return (hostID, host) - } - ) - kwtInventoryGeneration += 1 - let generation = kwtInventoryGeneration - kwtInventoryTask?.cancel() - guard !targets.isEmpty else { - kwtInventoryTask = nil - isKwtInventoryLoading = false - inventoryRefreshProgress.kwtCompleted = true - updateWorkspaceInventoryState() - return + return host + }() + return WorkspaceInventoryStore.HostRegistration( + hostID: hostID, + commandHost: commandHost, + provisioningHost: provisioningHost + ) } - inventoryRefreshProgress.kwtCompleted = false - isKwtInventoryLoading = true - updateWorkspaceInventoryState() - let kwtInventoryLoader = kwtInventoryLoader - let kwtRemoteProvisioner = kwtRemoteProvisioner - kwtInventoryTask = Task { [weak self] in - await withTaskGroup( - of: ( - UUID, - CommandHost, - KwtInventoryRefreshOutcome - ).self - ) { group in - for (hostID, host) in targets { - group.addTask { - if let remoteHost = - automaticProvisioningHosts[hostID] { - do { - try await kwtRemoteProvisioner(remoteHost) - } catch { - return ( - hostID, - host, - .provisioningFailed - ) + workspaceInventoryStore.updateSubscriber( + id: workspaceInventorySubscriberID, + registrations: registrations, + wantsKwt: kwtInventoryEnabled, + wantsTmux: tmuxDiscoveryEnabled + ) + consumeSharedInventory(workspaceInventoryStore.snapshot) + } + + private func consumeSharedInventory( + _ shared: WorkspaceInventoryStore.Snapshot + ) { + guard !isShutDown, !isConsumingSharedInventory else { return } + isConsumingSharedInventory = true + defer { isConsumingSharedInventory = false } + var successfulKwtHosts: [(UUID, CommandHost, KwtHostInventory)] = [] + var successfulTmuxHostIDs: Set = [] + + for (hostID, commandHost) in inventoryHosts { + let applicationKey = SharedInventoryApplicationKey( + hostID: hostID, + commandHost: commandHost + ) + if kwtInventoryEnabled, + let entry = shared.kwtByHost[commandHost] { + if entry.inventoryRevision + > appliedKwtInventoryRevisions[ + applicationKey, + default: 0 + ], + let inventory = entry.inventory { + appliedKwtInventoryRevisions[applicationKey] = + entry.inventoryRevision + let recordsSuccessfulLoad = entry.isFresh + && { + if case .loaded = entry.state { + return true } - } - do { - return await ( - hostID, - host, - .loaded( - try kwtInventoryLoader(host) - ) - ) - } catch { - return (hostID, host, .inventoryFailed(error)) - } + return false + }() + applyAuthoritativeKwtInventory( + inventory, + hostID: hostID, + publish: false, + publishToStore: false, + recordsSuccessfulLoad: recordsSuccessfulLoad + ) + if recordsSuccessfulLoad { + successfulKwtHosts.append(( + hostID, + commandHost, + inventory + )) } } - for await (hostID, sourceHost, outcome) in group { - guard let self, !Task.isCancelled, - generation == self.kwtInventoryGeneration else { - group.cancelAll() - return - } - switch outcome { - case let .loaded(inventory): - let tombstones = - self.activeRemovalTombstones( - after: inventory, - hostID: hostID - ) - self.applyAuthoritativeKwtInventory( - inventory, - hostID: hostID, - excludingWorktrees: tombstones, - publish: false - ) + if entry.observationRevision + > appliedKwtObservationRevisions[ + applicationKey, + default: 0 + ] { + appliedKwtObservationRevisions[applicationKey] = + entry.observationRevision + switch entry.state { + case .idle, .loading, .loaded: + break case .provisioningFailed: - // Remote kwt is optional. Keep passive maintenance - // failures private so terminal inventory and recovery - // stay independent while explicit worktree actions can - // repair the managed helper when they need it. - self.kwtAvailabilityByHost[hostID] = false - self.kwtInventoryFailuresByHost.removeValue( + kwtAvailabilityByHost[hostID] = false + kwtInventoryFailuresByHost.removeValue( forKey: hostID ) - case let .inventoryFailed(error): - if self.isRemoteKwtUnavailable( - error, - hostID: hostID - ) { - self.kwtAvailabilityByHost[hostID] = false - self.kwtInventoryFailuresByHost.removeValue( + case let .failed(error): + if isRemoteKwtUnavailable(error, hostID: hostID) { + kwtAvailabilityByHost[hostID] = false + kwtInventoryFailuresByHost.removeValue( forKey: hostID ) } else { - self.kwtInventoryFailuresByHost[hostID] = + kwtInventoryFailuresByHost[hostID] = error.localizedDescription } } - self.applyHostInventoryOverlayIfNeeded( + } + } + + if tmuxDiscoveryEnabled, + let entry = shared.tmuxByHost[commandHost] { + if entry.inventoryRevision + > appliedTmuxInventoryRevisions[ + applicationKey, + default: 0 + ], + let sessions = entry.sessions { + appliedTmuxInventoryRevisions[applicationKey] = + entry.inventoryRevision + applyTmuxDiscoveryResult( + .success(sessions), hostID: hostID, - includeKwtInventory: true + publish: false ) - if case let .loaded(inventory) = outcome { - self.reconcileRetainedTmuxPresentations( - afterAuthoritativeInventoryFor: hostID - ) - self.resolveQuarantinedProjectRemovals( - after: inventory, + successfulTmuxHostIDs.insert(hostID) + } + if entry.observationRevision + > appliedTmuxObservationRevisions[ + applicationKey, + default: 0 + ] { + appliedTmuxObservationRevisions[applicationKey] = + entry.observationRevision + _ = beginTmuxDiscoveryObservation(hostID: hostID) + if case let .failed(error) = entry.state { + applyTmuxDiscoveryResult( + .failure(error), hostID: hostID, - sourceHost: sourceHost + publish: false ) } - self.updateWorkspaceInventoryState() } } - guard let self, !Task.isCancelled, - generation == kwtInventoryGeneration else { return } - isKwtInventoryLoading = false - inventoryRefreshProgress.kwtCompleted = true - updateWorkspaceInventoryState() + } + + applySharedInventoryProgress(shared) + applyInventoryOverlayIfNeeded() + for (hostID, commandHost, inventory) in successfulKwtHosts { + reconcileRetainedTmuxPresentations( + afterAuthoritativeInventoryFor: hostID + ) + resolveQuarantinedProjectRemovals( + after: inventory, + hostID: hostID, + sourceHost: commandHost + ) + } + if !successfulTmuxHostIDs.isEmpty { + applyDeferredTmuxPresentationsIfReady() + for hostID in successfulTmuxHostIDs { + reconcileAlwaysLiveTmuxPresentations(hostID: hostID) + } + } + updateWorkspaceInventoryState() + } + + private func applySharedInventoryProgress( + _ shared: WorkspaceInventoryStore.Snapshot + ) { + let commandHosts = Set(inventoryHosts.values) + if kwtInventoryEnabled { + let entries = commandHosts.map { shared.kwtByHost[$0] } + isKwtInventoryLoading = entries.contains { entry in + guard let entry else { return true } + switch entry.state { + case .idle, .loading: + return true + case .loaded: + // Provisional rows are not a completed refresh. + return !entry.isFresh + case .failed, .provisioningFailed: + return false + } + } + inventoryRefreshProgress.kwtCompleted = + !isKwtInventoryLoading + } + if tmuxDiscoveryEnabled { + let entries = commandHosts.map { shared.tmuxByHost[$0] } + isTmuxDiscoveryLoading = entries.contains { entry in + guard let entry else { return true } + if case .idle = entry.state { + return true + } + if case .loading = entry.state { + return true + } + return false + } + inventoryRefreshProgress.tmuxCompleted = + !isTmuxDiscoveryLoading + } + } + + private func kwtMutationPublication( + hostID: UUID + ) -> WorkspaceInventoryStore.MutationPublication? { + inventoryHosts[hostID].map { + .init( + hostID: hostID, + host: $0, + epoch: workspaceInventoryStore.kwtMutationEpoch(on: $0) + ) } } + private func scheduleKwtInventory() { + guard kwtInventoryEnabled, + !ownsWorktreeMutation else { return } + workspaceInventoryStore.refreshKwt( + for: workspaceInventorySubscriberID + ) + } + private func invalidateKwtInventoryRefresh() { - kwtInventoryGeneration += 1 - kwtInventoryTask?.cancel() - kwtInventoryTask = nil isKwtInventoryLoading = false inventoryRefreshProgress.kwtCompleted = false updateWorkspaceInventoryState() @@ -4137,6 +4285,8 @@ final class WorkspaceSceneModel: ObservableObject { .formUnion(event.removalTombstones) retainPresentationsForFailedRemoval(event) return + case .registered: + return case .quarantined: fencedWorktreeMutationScopes.remove(event.scope) case .ended: @@ -4152,10 +4302,11 @@ final class WorkspaceSceneModel: ObservableObject { dismissSelectedWorktreePresentation(in: event.scope) } if event.removesProject { - applyProjectRemoval(scope: event.scope) + applyProjectRemoval( + scope: event.scope, + projectPath: event.projectPath + ) } else if !event.removalTombstones.isEmpty { - worktreeRemovalTombstones[event.scope, default: []] - .formUnion(event.removalTombstones) applyRemovalTombstones( event.removalTombstones, scope: event.scope @@ -4181,11 +4332,8 @@ final class WorkspaceSceneModel: ObservableObject { } } guard inventoryHosts[event.scope.hostID] != nil else { return } - invalidateKwtInventoryRefresh() - scheduleKwtInventory() - if event.phase == .ended { - scheduleTmuxSessionDiscovery() - } + applySharedInventoryProgress(workspaceInventoryStore.snapshot) + updateWorkspaceInventoryState() } private func retryProtectedTmuxAttachments( @@ -4208,18 +4356,33 @@ final class WorkspaceSceneModel: ObservableObject { } } + /// Removes the project a completed removal named, using the shared + /// cache's rule: two non-empty identities compare by identity, and when + /// either side is legacy-empty only the removed project's path applies. private func applyProjectRemoval( - scope: WorktreeMutationCoordinator.Scope + scope: WorktreeMutationCoordinator.Scope, + projectPath: String? ) { + let removedPath = projectPath.map(normalizedWorkspacePath) + func removes(repository: String, path: String) -> Bool { + let samePath = removedPath.map { + normalizedWorkspacePath(path) == $0 + } + if !scope.projectIdentity.isEmpty, !repository.isEmpty { + return repository == scope.projectIdentity + && samePath != false + } + return samePath == true + } if var inventory = kwtInventoriesByHost[scope.hostID] { inventory.projects.removeAll { - $0.project.repository == scope.projectIdentity + removes(repository: $0.project.repository, path: $0.project.path) } kwtInventoriesByHost[scope.hostID] = inventory } let projectIDs = Set(snapshot.projects.compactMap { project in project.hostID == scope.hostID - && project.scopedKey == scope.projectIdentity + && removes(repository: project.scopedKey, path: project.rootPath) ? project.id : nil }) let worktreeIDs = Set(snapshot.worktrees.compactMap { worktree in @@ -4902,63 +5065,56 @@ final class WorkspaceSceneModel: ObservableObject { } } - private func activeRemovalTombstones( - after inventory: KwtHostInventory, - hostID: UUID - ) -> [String: Set] { - var activeTombstones: [String: Set] = [:] - let scopes = worktreeRemovalTombstones.keys.filter { - $0.hostID == hostID - } - for scope in scopes { - guard let tombstones = worktreeRemovalTombstones[scope] else { - continue - } - let project = inventory.projects.first { - $0.project.repository == scope.projectIdentity - } - let active = tombstones.filter { tombstone in - guard let project else { return false } - if project.warning != nil { - return true - } - return project.worktrees.contains { - Self.removalTombstones( - [tombstone], - matchPath: $0.path, - generation: $0.generation - ) - } - } - if active.isEmpty { - worktreeRemovalTombstones.removeValue(forKey: scope) - } else { - worktreeRemovalTombstones[scope] = active - activeTombstones[scope.projectIdentity, default: []] - .formUnion(active) - } - } - return activeTombstones - } - private func applyAuthoritativeKwtInventory( _ inventory: KwtHostInventory, hostID: UUID, excludingWorktrees: [String: Set] = [:], - publish: Bool = true + publish: Bool = true, + publishToStore: Bool = true, + mutation: WorkspaceInventoryStore.MutationPublication? = nil, + recordsSuccessfulLoad: Bool = true ) { - worktreeMutationCoordinator.reconcileRetiredProtectedEndpoints( - after: inventory, - hostID: hostID - ) - let previous = kwtInventoriesByHost[hostID] - kwtInventoriesByHost[hostID] = - inventory.retainingFailedProjectWorktrees( - from: previous, - excludingWorktrees: excludingWorktrees + // A result that predates a later mutation on the host still shows + // this scene its own mutation, but it is provisional: it is neither + // authoritative here nor shared, and the fence-end reload replaces it. + let isStaleMutation = mutation.map { mutation in + inventoryHosts[hostID].map { + $0 != mutation.host + || workspaceInventoryStore.kwtMutationEpoch(on: $0) + != mutation.epoch + } ?? true + } ?? false + let recordsSuccessfulLoad = recordsSuccessfulLoad && !isStaleMutation + let publishToStore = publishToStore && !isStaleMutation + if recordsSuccessfulLoad { + worktreeMutationCoordinator.reconcileRetiredProtectedEndpoints( + after: inventory, + hostID: hostID ) - kwtAvailabilityByHost[hostID] = true - kwtInventoryFailuresByHost.removeValue(forKey: hostID) + } + let previous = kwtInventoriesByHost[hostID] + var exclusions = inventoryHosts[hostID].map { + workspaceInventoryStore.removalTombstones(on: $0) + } ?? [:] + for (repository, identities) in excludingWorktrees { + exclusions[repository, default: []].formUnion(identities) + } + var reconciled = inventory.retainingFailedProjectWorktrees( + from: previous, + excludingWorktrees: exclusions + ) + if let commandHost = inventoryHosts[hostID] { + let removedProjects = workspaceInventoryStore + .projectRemovalTombstones(on: commandHost) + reconciled.projects.removeAll { item in + removedProjects.contains { $0.matches(item.project) } + } + } + kwtInventoriesByHost[hostID] = reconciled + if recordsSuccessfulLoad { + kwtAvailabilityByHost[hostID] = true + kwtInventoryFailuresByHost.removeValue(forKey: hostID) + } if publish { applyInventoryOverlayIfNeeded() reconcileRetainedTmuxPresentations( @@ -4966,6 +5122,21 @@ final class WorkspaceSceneModel: ObservableObject { ) updateWorkspaceInventoryState() } + if publishToStore, let commandHost = inventoryHosts[hostID] { + let wasConsumingSharedInventory = isConsumingSharedInventory + isConsumingSharedInventory = true + workspaceInventoryStore.publishKwtInventory( + inventory, + on: commandHost, + excludingWorktrees: excludingWorktrees, + mutation: mutation, + recordsSuccessfulLoad: recordsSuccessfulLoad + ) + isConsumingSharedInventory = wasConsumingSharedInventory + if !wasConsumingSharedInventory { + consumeSharedInventory(workspaceInventoryStore.snapshot) + } + } } private func resolveQuarantinedProjectRemovals( @@ -4988,9 +5159,17 @@ final class WorkspaceSceneModel: ObservableObject { continue } let projectPath = quarantine.projectPath - if let item = inventory.projects.first(where: { + // The same repository healthy at another path is a different + // project; only a survivor at the quarantined path restores. + let survivor = inventory.projects.first { $0.project.repository == scope.projectIdentity - }) { + && normalizedWorkspacePath($0.project.path) + == normalizedWorkspacePath(projectPath) + } ?? inventory.projects.first { + $0.project.repository == scope.projectIdentity + && $0.warning != nil + } + if let item = survivor { if item.warning != nil { guard normalizedWorkspacePath(item.project.path) == normalizedWorkspacePath(projectPath) @@ -5025,7 +5204,8 @@ final class WorkspaceSceneModel: ObservableObject { removalTombstones: worktreeMutationCoordinator .pendingRemovals[scope] ?? [], removesProject: true, - allowsRemovalRestoration: false + allowsRemovalRestoration: false, + projectPath: projectPath ) } } @@ -5151,65 +5331,15 @@ final class WorkspaceSceneModel: ObservableObject { func startTmuxSessionDiscovery() { guard !tmuxDiscoveryEnabled else { return } tmuxDiscoveryEnabled = true - let generation = tmuxDiscoveryGeneration reconcileInventoryHosts() - if generation == tmuxDiscoveryGeneration { - scheduleTmuxSessionDiscovery() - } + updateSharedInventorySubscription() } private func scheduleTmuxSessionDiscovery() { guard tmuxDiscoveryEnabled else { return } - let targets = inventoryHosts.map { hostID, host in - ( - hostID, - host, - beginTmuxDiscoveryObservation(hostID: hostID) - ) - } - tmuxDiscoveryGeneration += 1 - let generation = tmuxDiscoveryGeneration - tmuxDiscoveryTask?.cancel() - inventoryRefreshProgress.tmuxCompleted = false - isTmuxDiscoveryLoading = true - updateWorkspaceInventoryState() - let broker = tmuxSessionProbeBroker - tmuxDiscoveryTask = Task { [weak self] in - await withTaskGroup( - of: ( - UUID, - UInt64, - Result<[DiscoveredTmuxSession], TmuxBinaryError> - ).self - ) { group in - for (hostID, host, observationSequence) in targets { - group.addTask { - await ( - hostID, - observationSequence, - broker.sessions(on: host) - ) - } - } - for await (hostID, observationSequence, result) in group { - guard let self, !Task.isCancelled, - generation == self.tmuxDiscoveryGeneration else { - group.cancelAll() - return - } - guard self.isCurrentTmuxDiscoveryObservation( - observationSequence, - hostID: hostID - ) else { continue } - self.applyTmuxDiscoveryResult(result, hostID: hostID) - } - } - guard let self, !Task.isCancelled, - generation == tmuxDiscoveryGeneration else { return } - isTmuxDiscoveryLoading = false - inventoryRefreshProgress.tmuxCompleted = true - updateWorkspaceInventoryState() - } + workspaceInventoryStore.refreshTmux( + for: workspaceInventorySubscriberID + ) } private func beginTmuxDiscoveryObservation(hostID: UUID) -> UInt64 { @@ -5226,6 +5356,12 @@ final class WorkspaceSceneModel: ObservableObject { latestTmuxDiscoveryObservationByHost[hostID] == sequence } + private func tmuxRefreshEpoch(hostID: UUID) -> UInt64? { + inventoryHosts[hostID].map { + workspaceInventoryStore.tmuxRefreshEpoch(on: $0) + } + } + func startHerdrSessionDiscovery() { guard !isShutDown, !herdrDiscoveryEnabled else { return } herdrDiscoveryEnabled = true @@ -5587,10 +5723,14 @@ final class WorkspaceSceneModel: ObservableObject { } } + /// Applies a scene-local tmux discovery. Passing `publishingEpoch`, the + /// store epoch captured before the probe started, shares the result with + /// every window unless a newer shared refresh has begun since. private func applyTmuxDiscoveryResult( _ result: Result<[DiscoveredTmuxSession], TmuxBinaryError>, hostID: UUID, - publish: Bool = true + publish: Bool = true, + publishingEpoch: UInt64? = nil ) { guard let discovered = recordTmuxDiscoveryState( result, @@ -5607,6 +5747,13 @@ final class WorkspaceSceneModel: ObservableObject { discovered, hostID: hostID ) + if let publishingEpoch, let commandHost = inventoryHosts[hostID] { + workspaceInventoryStore.publishTmuxSessions( + discovered, + on: commandHost, + epoch: publishingEpoch + ) + } for presentation in retainedTmuxPresentations.values { guard var context = presentation.reconnectContext, context.phase == .establishingWorkspace, @@ -5676,9 +5823,6 @@ final class WorkspaceSceneModel: ObservableObject { host: CommandHost ) { tmuxSessionProbeBroker.invalidateSessions(on: host) - tmuxDiscoveryGeneration += 1 - tmuxDiscoveryTask?.cancel() - tmuxDiscoveryTask = nil isTmuxDiscoveryLoading = false inventoryRefreshProgress.tmuxCompleted = false if tmuxDiscoveryEnabled { @@ -6798,6 +6942,30 @@ final class WorkspaceSceneModel: ObservableObject { ) } + /// Announces a registration once per command host; the store applies it + /// to every host identity that resolves there. + private func noteProjectRegistration( + _ project: KwtProjectRecord, + on target: CommandHost + ) { + guard let hostID = inventoryHosts + .filter({ $0.value == target }) + .keys.min(by: { $0.uuidString < $1.uuidString }) + else { + workspaceInventoryStore.noteProjectRegistration( + on: target, + projectIdentity: project.repository, + projectPath: project.path + ) + return + } + worktreeMutationCoordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: project.repository, + projectPath: project.path + ) + } + private func performProjectRegistration( _ projectPath: String, on target: CommandHost, @@ -6841,7 +7009,7 @@ final class WorkspaceSceneModel: ObservableObject { projectPath, target ) - refreshKwtInventory() + noteProjectRegistration(project, on: target) return .success(project.name) } catch { if let hostID { @@ -6955,7 +7123,7 @@ final class WorkspaceSceneModel: ObservableObject { )) } invalidateKwtInventoryRefresh() - var shouldRefresh = false + let mutation = kwtMutationPublication(hostID: initial.project.hostID) var removalTombstones: Set = [] var removesProject = false @@ -6977,12 +7145,10 @@ final class WorkspaceSceneModel: ObservableObject { reconciledRestorationTargets: reconciledRestorationTargets, removesProject: removesProject, - allowsRemovalRestoration: allowsRemovalRestoration + allowsRemovalRestoration: allowsRemovalRestoration, + projectPath: initial.project.rootPath ) } - if shouldRefresh { - refreshKwtInventory() - } } do { @@ -7038,12 +7204,12 @@ final class WorkspaceSceneModel: ObservableObject { confirmedHostID: confirmedHost.id, capturedTarget: capturedTarget ) != nil else { - shouldRefresh = true return .failure(projectRemovalTargetChangedError) } applyAuthoritativeKwtInventory( refreshed, - hostID: initial.project.hostID + hostID: initial.project.hostID, + publishToStore: false ) guard let removal = validatedProjectRemovalTarget( project, @@ -7089,14 +7255,10 @@ final class WorkspaceSceneModel: ObservableObject { removal.project, on: removal.host ) - if quarantinesProjectRemoval { - shouldRefresh = true - } return .failure(projectRemovalTargetChangedError) } removalTombstones = preparedTombstones removesProject = true - shouldRefresh = true return .success(removed.name) } catch { let removalError = error @@ -7118,14 +7280,12 @@ final class WorkspaceSceneModel: ObservableObject { capturedTarget: capturedTarget ) != nil else { allowsRemovalRestoration = false - shouldRefresh = true return .failure(projectRemovalTargetChangedError) } reconciledRestorationTargets = projectRestorationTargets( hostID: removal.project.hostID, projectIdentity: removal.project.scopedKey ) - shouldRefresh = code == "registration_changed" return .failure(.message( removalError.localizedDescription )) @@ -7133,7 +7293,8 @@ final class WorkspaceSceneModel: ObservableObject { let reconciliation = await reconcileFailedProjectRemoval( removal.project, on: removal.host, - expectedRouteIdentity: request.routeIdentity + expectedRouteIdentity: request.routeIdentity, + mutation: mutation ) guard snapshot.host(id: confirmedHost.id) .flatMap(CommandHostResolver.resolve) == capturedTarget @@ -7144,16 +7305,12 @@ final class WorkspaceSceneModel: ObservableObject { removal.project, on: removal.host ) - if quarantinesProjectRemoval { - shouldRefresh = true - } return .failure(projectRemovalTargetChangedError) } switch reconciliation { case .removed: removalTombstones = preparedTombstones removesProject = true - shouldRefresh = true return .success(removal.project.name) case let .present(restorationTargets): reconciledRestorationTargets = restorationTargets @@ -7169,7 +7326,6 @@ final class WorkspaceSceneModel: ObservableObject { projectPath: removal.project.rootPath, host: removal.host ) - shouldRefresh = true return .failure(.message( removalError.localizedDescription )) @@ -7228,7 +7384,8 @@ final class WorkspaceSceneModel: ObservableObject { private func reconcileFailedProjectRemoval( _ project: ProjectSummary, on host: CommandHost, - expectedRouteIdentity: String? + expectedRouteIdentity: String?, + mutation: WorkspaceInventoryStore.MutationPublication? ) async -> FailedProjectRemovalReconciliation { do { let inventory = try await removalReconciliationInventory( @@ -7253,7 +7410,8 @@ final class WorkspaceSceneModel: ObservableObject { } applyAuthoritativeKwtInventory( inventory, - hostID: project.hostID + hostID: project.hostID, + mutation: mutation ) guard normalizedWorkspacePath(repositoryItem.project.path) == normalizedWorkspacePath(project.rootPath) @@ -7269,13 +7427,15 @@ final class WorkspaceSceneModel: ObservableObject { }) { applyAuthoritativeKwtInventory( inventory, - hostID: project.hostID + hostID: project.hostID, + mutation: mutation ) return .unverified } applyAuthoritativeKwtInventory( inventory, - hostID: project.hostID + hostID: project.hostID, + mutation: mutation ) return .removed } catch { @@ -11959,16 +12119,22 @@ final class WorkspaceSceneModel: ObservableObject { } } if let discovery = probe.discovery { - if isCurrentTmuxDiscoveryObservation( - discovery.sequence, - hostID: context.selection.hostID - ) { + let refreshStarted = discovery.epoch + != tmuxRefreshEpoch(hostID: context.selection.hostID) + if !refreshStarted, + isCurrentTmuxDiscoveryObservation( + discovery.sequence, + hostID: context.selection.hostID + ) { applyTmuxDiscoveryResult( discovery.result, - hostID: context.selection.hostID + hostID: context.selection.hostID, + publishingEpoch: discovery.epoch ) } else { - scheduleTmuxSessionDiscovery() + if !refreshStarted { + scheduleTmuxSessionDiscovery() + } // A concurrent inventory pass superseded this observation. // A route-fenced positive probe can still attach safely, but // absence or failure must yield to the newer observation. @@ -12088,6 +12254,7 @@ final class WorkspaceSceneModel: ObservableObject { let sequence = beginTmuxDiscoveryObservation( hostID: context.selection.hostID ) + let epoch = tmuxRefreshEpoch(hostID: context.selection.hostID) let result = await tmuxSessionValidationDiscovery( context.host, connection.arguments @@ -12102,7 +12269,7 @@ final class WorkspaceSceneModel: ObservableObject { } return TmuxReconnectProbeResult( outcome: outcome, - discovery: (sequence, result) + discovery: (sequence, epoch, result) ) } @@ -12114,6 +12281,7 @@ final class WorkspaceSceneModel: ObservableObject { let observationSequence = beginTmuxDiscoveryObservation( hostID: context.selection.hostID ) + let epoch = tmuxRefreshEpoch(hostID: context.selection.hostID) var didReconcileObservation = false defer { if !didReconcileObservation, @@ -12121,7 +12289,8 @@ final class WorkspaceSceneModel: ObservableObject { isCurrentTmuxDiscoveryObservation( observationSequence, hostID: context.selection.hostID - ) { + ), + tmuxRefreshEpoch(hostID: context.selection.hostID) == epoch { scheduleTmuxSessionDiscovery() } } @@ -12149,7 +12318,9 @@ final class WorkspaceSceneModel: ObservableObject { guard isCurrentTmuxDiscoveryObservation( observationSequence, hostID: context.selection.hostID - ) else { + ), + tmuxRefreshEpoch(hostID: context.selection.hostID) == epoch + else { return .failure(.probeCancelled( shell: context.host.displayName )) @@ -12157,7 +12328,8 @@ final class WorkspaceSceneModel: ObservableObject { didReconcileObservation = true applyTmuxDiscoveryResult( result, - hostID: context.selection.hostID + hostID: context.selection.hostID, + publishingEpoch: epoch ) switch result { case let .success(sessions): @@ -12841,6 +13013,7 @@ final class WorkspaceSceneModel: ObservableObject { let observationSequence = beginTmuxDiscoveryObservation( hostID: pending.selection.hostID ) + let epoch = workspaceInventoryStore.tmuxRefreshEpoch(on: host) let probe = Task.detached(priority: .utility) { await discovery(host) } @@ -12855,7 +13028,9 @@ final class WorkspaceSceneModel: ObservableObject { guard isCurrentTmuxDiscoveryObservation( observationSequence, hostID: pending.selection.hostID - ) else { + ), + workspaceInventoryStore.tmuxRefreshEpoch(on: host) == epoch + else { if index == delays.indices.last { createdSessionDiscoveryTasks.removeValue( forKey: handleID @@ -12888,6 +13063,11 @@ final class WorkspaceSceneModel: ObservableObject { ) applyInventoryOverlayIfNeeded() updateWorkspaceInventoryState() + workspaceInventoryStore.publishTmuxSessions( + discovered, + on: host, + epoch: epoch + ) if found { applyDeferredTmuxPresentationsIfReady() } diff --git a/Sources/App/WorktreeMutationCoordinator.swift b/Sources/App/WorktreeMutationCoordinator.swift index 89f17c73..6ba8788b 100644 --- a/Sources/App/WorktreeMutationCoordinator.swift +++ b/Sources/App/WorktreeMutationCoordinator.swift @@ -28,6 +28,7 @@ final class WorktreeMutationCoordinator { case willRemove case quarantined case ended + case registered } struct Event: Sendable { @@ -42,6 +43,9 @@ final class WorktreeMutationCoordinator { let requiresWorkspaceReestablishment: Bool let removesProject: Bool let allowsRemovalRestoration: Bool + /// The project's root path for project removal and registration, + /// which identifies it even under a legacy-empty repository identity. + var projectPath: String? } struct QuarantinedProjectRemoval: Equatable, Sendable { @@ -272,7 +276,8 @@ final class WorktreeMutationCoordinator { Set? = nil, requiresWorkspaceReestablishment: Bool = false, removesProject: Bool = false, - allowsRemovalRestoration: Bool = true + allowsRemovalRestoration: Bool = true, + projectPath: String? = nil ) { let scope = Scope( hostID: hostID, @@ -296,7 +301,8 @@ final class WorktreeMutationCoordinator { requiresWorkspaceReestablishment: requiresWorkspaceReestablishment, removesProject: removesProject, - allowsRemovalRestoration: allowsRemovalRestoration + allowsRemovalRestoration: allowsRemovalRestoration, + projectPath: projectPath ) ) } @@ -328,6 +334,31 @@ final class WorktreeMutationCoordinator { ) } + /// Announces that a project was registered so every scene and the shared + /// inventory cache forget the removal tombstones for that repository. + func noteProjectRegistration( + hostID: UUID, + projectIdentity: String, + projectPath: String + ) { + eventSubject.send( + Event( + phase: .registered, + scope: Scope( + hostID: hostID, + projectIdentity: projectIdentity + ), + removalTombstones: [], + removalPresentationTargets: [], + reconciledRestorationTargets: nil, + requiresWorkspaceReestablishment: false, + removesProject: false, + allowsRemovalRestoration: true, + projectPath: projectPath + ) + ) + } + func quarantineProjectRemoval( hostID: UUID, projectIdentity: String, diff --git a/Tests/App/SceneModelTestSupport.swift b/Tests/App/SceneModelTestSupport.swift index e367338b..7e840e71 100644 --- a/Tests/App/SceneModelTestSupport.swift +++ b/Tests/App/SceneModelTestSupport.swift @@ -302,6 +302,7 @@ func makeModel( database: WorkspaceDatabase, localHostID: UUID, snapshot: WorkspaceSnapshot? = nil, + workspaceInventoryStore: WorkspaceInventoryStore? = nil, configuration: WorkspaceConfiguration = .defaults(), terminalRuntime: LibghosttyRuntime = .shared, notificationService: any NotificationService = NotificationServiceStub(), @@ -544,6 +545,13 @@ func makeModel( SessionReconnectSupervisor.defaultProbeDeadline, startServices: Bool = false ) throws -> WorkspaceSceneModel { + let resolvedWorkspaceInventoryStore = workspaceInventoryStore + ?? WorkspaceInventoryStore( + kwtLoader: kwtInventoryLoader, + kwtProvisioner: kwtRemoteProvisioner, + tmuxLoader: tmuxSessionDiscovery, + mutationCoordinator: worktreeMutationCoordinator + ) return try WorkspaceSceneModel( database: database, workspaceConfiguration: configuration, @@ -577,6 +585,7 @@ func makeModel( kwtWorktreeChangeReader: kwtWorktreeChangeReader, sshRouteIdentityResolver: sshRouteIdentityResolver, worktreeMutationCoordinator: worktreeMutationCoordinator, + workspaceInventoryStore: resolvedWorkspaceInventoryStore, herdrLifecycleCoordinator: herdrLifecycleCoordinator, zellijSessionKillCoordinator: zellijSessionKillCoordinator, herdrSessionRecordReader: herdrSessionRecordReader, diff --git a/Tests/App/WorkspaceHerdrPresentationTests.swift b/Tests/App/WorkspaceHerdrPresentationTests.swift index 24165b91..d1fd67a1 100644 --- a/Tests/App/WorkspaceHerdrPresentationTests.swift +++ b/Tests/App/WorkspaceHerdrPresentationTests.swift @@ -1630,7 +1630,7 @@ struct WorkspaceHerdrPresentationTests { let close = try #require(store.surface.closeObservers.values.first) close(false, 255) - await waitUntilMainActor(timeout: .seconds(1)) { + await waitUntilMainActor { store.requestedConfigurations.count == 2 && model.activeBorrowedHerdrConnectionState == .connected } diff --git a/Tests/App/WorkspaceInventoryStoreTests.swift b/Tests/App/WorkspaceInventoryStoreTests.swift new file mode 100644 index 00000000..a8c4ea0e --- /dev/null +++ b/Tests/App/WorkspaceInventoryStoreTests.swift @@ -0,0 +1,3179 @@ +import AppKit +import Combine +import Foundation +import GhosthubSettings +import GhosthubTransport +import Testing +@testable import GhosthubApp + +@Suite("Workspace inventory store", .serialized) +@MainActor +struct WorkspaceInventoryStoreTests { + @Test("starting a KWT refresh revokes cached freshness") + func startingKwtRefreshRevokesFreshness() async { + let loadGate = AsyncGate() + let cadenceGate = AsyncGate() + let sleepCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + await loadGate.wait() + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + sleep: { _ in + let attempt = sleepCount.load() + sleepCount.withLock { $0 += 1 } + if attempt == 0 { + await cadenceGate.wait() + } else { + try await Task.sleep(for: .seconds(3_600)) + } + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + defer { + loadGate.open() + store.removeSubscriber(id: subscriberID) + } + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: nil + ) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + + await cadenceGate.waitUntilWaiting() + cadenceGate.open() + await waitUntilMainActor { + guard let entry = store.snapshot.kwtByHost[.local] else { + return false + } + if case .loading = entry.state { + return true + } + return false + } + + guard let entry = store.snapshot.kwtByHost[.local] else { + Issue.record("Expected cached KWT inventory") + return + } + guard case .loading = entry.state else { + Issue.record("Expected a loading KWT entry") + return + } + #expect(entry.isFresh == false) + } + + @Test("application activity monitoring refreshes once on reactivation") + func applicationActivityMonitoringIsProcessWide() async throws { + let center = NotificationCenter() + let kwtCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + store.startApplicationActivityMonitoring( + center: center, + initialIsActive: false + ) + store.startApplicationActivityMonitoring( + center: center, + initialIsActive: false + ) + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + try await Task.sleep(for: .milliseconds(20)) + #expect(kwtCount.load() == 0) + + center.post(name: NSApplication.didBecomeActiveNotification, object: nil) + await waitUntil { kwtCount.load() == 1 } + try await Task.sleep(for: .milliseconds(20)) + #expect(kwtCount.load() == 1) + } + + @Test("cadence pauses inactive and refreshes once on reactivation") + func cadenceFollowsApplicationActivity() async throws { + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let sleepDurations = LockedValue<[Duration]>([]) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxCount.withLock { $0 += 1 } + return .success([]) + }, + sleep: { duration in + sleepDurations.withLock { $0.append(duration) } + try await Task.sleep(for: .seconds(3_600)) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + store.setApplicationActive(false) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: true + ) + try await Task.sleep(for: .milliseconds(20)) + #expect(kwtCount.load() == 0) + #expect(tmuxCount.load() == 0) + + store.setApplicationActive(true) + await waitUntil { + kwtCount.load() == 1 && tmuxCount.load() == 1 + && sleepDurations.load() == [.seconds(30)] + } + + store.setApplicationActive(false) + store.setApplicationActive(true) + await waitUntil { + kwtCount.load() == 2 && tmuxCount.load() == 2 + && sleepDurations.load().count == 2 + } + } + + @Test("reactivation replaces loads started before inactivity") + func reactivationReplacesInFlightLoads() async { + let staleKwt = KwtHostInventory( + projects: [], + projectsWarning: "stale" + ) + let freshKwt = KwtHostInventory(projects: []) + let staleTmux = DiscoveredTmuxSession( + name: "stale", + windowCount: 1, + createdAt: nil, + managed: false + ) + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let firstKwtLoad = AsyncGate() + let firstTmuxLoad = AsyncGate() + defer { + firstKwtLoad.open() + firstTmuxLoad.open() + } + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = kwtCount.load() + kwtCount.withLock { $0 += 1 } + if attempt == 0 { + await firstKwtLoad.wait() + return staleKwt + } + return freshKwt + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = tmuxCount.load() + tmuxCount.withLock { $0 += 1 } + if attempt == 0 { + await firstTmuxLoad.wait() + return .success([staleTmux]) + } + return .success([]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: true + ) + await waitUntil { + kwtCount.load() == 1 && tmuxCount.load() == 1 + } + + store.setApplicationActive(false) + store.setApplicationActive(true) + + await waitUntil { + kwtCount.load() == 2 && tmuxCount.load() == 2 + } + #expect(store.snapshot.kwtByHost[.local]?.inventory == freshKwt) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == []) + } + + @Test("remote KWT loads provision the configured host once") + func remoteLoadsProvisionOnce() async { + let events = LockedValue<[String]>([]) + let commandHost = CommandHost.ssh(.init( + user: "test", + hostname: "example.invalid", + port: nil, + platform: .posix + )) + let configuredHost = SSHHost( + configKey: "test-linux", + name: "Test Linux", + platform: .linux, + sshDestination: "test@example.invalid" + ) + let store = WorkspaceInventoryStore( + kwtLoader: { host in + #expect(host == commandHost) + events.withLock { $0.append("load") } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { host in + #expect(host == configuredHost) + events.withLock { $0.append("provision") } + }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let registrations = [ + WorkspaceInventoryStore.HostRegistration( + hostID: UUID(), + commandHost: commandHost, + provisioningHost: configuredHost + ), + WorkspaceInventoryStore.HostRegistration( + hostID: UUID(), + commandHost: commandHost, + provisioningHost: configuredHost + ), + ] + + store.updateSubscriber( + id: UUID(), + registrations: registrations, + wantsKwt: true, + wantsTmux: false + ) + + await waitUntil { events.load().count == 2 } + #expect(events.load() == ["provision", "load"]) + } + + @Test("provisioning failure leaves KWT unread and tmux independent") + func provisioningFailureDoesNotBlockTmux() async { + enum ProvisioningFailure: Error { + case failed + } + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let commandHost = CommandHost.ssh(.init( + user: "test", + hostname: "example.invalid", + port: nil, + platform: .posix + )) + let configuredHost = SSHHost( + configKey: "test-linux", + name: "Test Linux", + platform: .linux, + sshDestination: "test@example.invalid" + ) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in throw ProvisioningFailure.failed }, + tmuxLoader: { _ in + tmuxCount.withLock { $0 += 1 } + return .success([]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: UUID(), + commandHost: commandHost, + provisioningHost: configuredHost + )], + wantsKwt: true, + wantsTmux: true + ) + + await waitUntilMainActor { + guard let kwt = store.snapshot.kwtByHost[commandHost], + let tmux = store.snapshot.tmuxByHost[commandHost], + case .provisioningFailed = kwt.state + else { return false } + return tmux.isFresh + } + #expect(kwtCount.load() == 0) + #expect(tmuxCount.load() == 1) + #expect(store.snapshot.kwtByHost[commandHost]?.isFresh == false) + } + + @Test("late results from a replaced endpoint are rejected") + func replacementRejectsLateResult() async throws { + let oldHost = CommandHost.ssh(.init( + user: "test", + hostname: "old.invalid", + port: nil, + platform: .posix + )) + let newHost = CommandHost.ssh(.init( + user: "test", + hostname: "new.invalid", + port: nil, + platform: .posix + )) + let oldGate = AsyncGate() + let oldCount = LockedValue(0) + let newCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { host in + if host == oldHost { + oldCount.withLock { $0 += 1 } + await oldGate.wait() + } else { + #expect(host == newHost) + newCount.withLock { $0 += 1 } + } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + let hostID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: oldHost, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntil { oldCount.load() == 1 } + + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: newHost, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[newHost]?.isFresh == true + } + oldGate.open() + try await Task.sleep(for: .milliseconds(20)) + + #expect(oldCount.load() == 1) + #expect(newCount.load() == 1) + #expect(store.snapshot.kwtByHost[oldHost]?.inventory == nil) + } + + @Test("mutation scopes fence commands until one reconciliation load") + func mutationScopesFenceCommands() async throws { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let projectIdentity = "example/repository" + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: projectIdentity + )) + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + try await Task.sleep(for: .milliseconds(20)) + #expect(loadCount.load() == 0) + + coordinator.release( + hostID: hostID, + projectIdentity: projectIdentity + ) + + await waitUntil { loadCount.load() == 1 } + } + + @Test("project removal cancels an old load and reconciles once") + func projectRemovalCancelsOldLoad() async throws { + let coordinator = WorktreeMutationCoordinator() + let firstLoad = AsyncGate() + let loadCount = LockedValue(0) + let hostID = UUID() + let projectIdentity = "example/repository" + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + let count = loadCount.load() + if count == 1 { + await firstLoad.wait() + } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntil { loadCount.load() == 1 } + + #expect(coordinator.acquireProjectRemoval( + hostID: hostID, + projectIdentity: projectIdentity, + registryHost: .init(target: .local) + )) + store.refreshKwt(for: subscriberID) + firstLoad.open() + try await Task.sleep(for: .milliseconds(20)) + #expect(loadCount.load() == 1) + + coordinator.release( + hostID: hostID, + projectIdentity: projectIdentity, + removesProject: true + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect(loadCount.load() == 2) + } + + @Test("authoritative mutation publication satisfies fence-end refresh") + func publicationSatisfiesFenceEndRefresh() async throws { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let projectIdentity = "example/repository" + let project = KwtProjectRecord( + repository: projectIdentity, + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let registered = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [], + warning: nil + )]) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: projectIdentity + )) + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: .init( + hostID: hostID, + host: .local, + epoch: store.kwtMutationEpoch(on: .local) + ) + ) + coordinator.release( + hostID: hostID, + projectIdentity: projectIdentity, + removesProject: true + ) + + try await Task.sleep(for: .milliseconds(20)) + #expect(loadCount.load() == 0) + #expect(store.snapshot.kwtByHost[.local]?.isFresh == true) + + store.publishKwtInventory( + registered, + on: .local, + mutation: nil + ) + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == registered.projects + ) + } + + @Test("removal exclusions survive stale refreshes until confirmed absent") + func removalExclusionsSurviveStaleRefreshes() async { + let repository = "example/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/removed", + branch: "feature/removed", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "removed-generation", + repository: repository, + sessionName: "kwt-feature-removed" + ) + let project = KwtProjectRecord( + repository: repository, + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let containing = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [worktree], + warning: nil + )]) + let absent = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [], + warning: nil + )]) + let loadCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + let count = loadCount.load() + return count == 2 ? absent : containing + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let identity = KwtWorktreeIdentity( + path: worktree.path, + generation: worktree.generation ?? "" + ) + store.publishKwtInventory( + containing, + on: .local, + excludingWorktrees: [repository: [identity]], + mutation: nil + ) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + } + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + } + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 3 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.count == 1 + } + } + + @Test("mutation-end tombstones survive stale refreshes") + func mutationEndTombstonesSurviveStaleRefreshes() async { + enum RefreshFailure: Error { + case failed + } + let repository = "example/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/removed", + branch: "feature/removed", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "removed-generation", + repository: repository, + sessionName: "kwt-feature-removed" + ) + let project = KwtProjectRecord( + repository: repository, + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let containing = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [worktree], + warning: nil + )]) + let absent = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [], + warning: nil + )]) + let coordinator = WorktreeMutationCoordinator() + let firstLoad = AsyncGate() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + switch loadCount.load() { + case 1: + await firstLoad.wait() + throw RefreshFailure.failed + case 2: + return absent + default: + return containing + } + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let identity = KwtWorktreeIdentity( + path: worktree.path, + generation: worktree.generation ?? "" + ) + store.publishKwtInventory( + containing, + on: .local, + mutation: nil + ) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removalTombstones: [identity] + ) + await waitUntil { loadCount.load() == 1 } + #expect( + store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + ) + + firstLoad.open() + await waitUntilMainActor { + guard let entry = store.snapshot.kwtByHost[.local] else { + return false + } + guard case .failed = entry.state else { return false } + return true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + ) + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + } + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 3 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees == [worktree] + } + } + + @Test("project mutation-end tombstones survive stale refreshes") + func projectMutationEndTombstonesSurviveStaleRefreshes() async { + let repository = "example/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/main", + branch: "main", + commitHash: "abc123", + isMain: true, + createdAt: nil, + generation: "main-generation", + repository: repository, + sessionName: "kwt-main" + ) + let project = KwtProjectRecord( + repository: repository, + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let containing = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [worktree], + warning: nil + )]) + let absent = KwtHostInventory(projects: []) + let coordinator = WorktreeMutationCoordinator() + let firstLoad = AsyncGate() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + if loadCount.load() == 1 { + await firstLoad.wait() + } + return loadCount.load() == 2 ? absent : containing + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + store.publishKwtInventory( + containing, + on: .local, + mutation: nil + ) + #expect(coordinator.acquireProjectRemoval( + hostID: hostID, + projectIdentity: repository, + registryHost: .init(target: .local) + )) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removesProject: true + ) + await waitUntil { loadCount.load() == 1 } + #expect( + store.snapshot.kwtByHost[.local]?.inventory? + .projects.isEmpty == true + ) + firstLoad.open() + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.isEmpty == true + } + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.isEmpty == true + } + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 3 + && store.snapshot.kwtByHost[.local]?.inventory? + .projects.first == containing.projects.first + } + } + + @Test("concurrent mutations preserve one fence-end reconciliation load") + func concurrentMutationsPreserveFenceEndRefresh() async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: "example/first" + )) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: "example/second" + )) + store.updateSubscriber( + id: UUID(), + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: .init( + hostID: hostID, + host: .local, + epoch: store.kwtMutationEpoch(on: .local) + ) + ) + + coordinator.release( + hostID: hostID, + projectIdentity: "example/first" + ) + coordinator.release( + hostID: hostID, + projectIdentity: "example/second" + ) + + await waitUntilMainActor { + loadCount.load() == 1 + } + #expect(loadCount.load() == 1) + } + + @Test("explicit refresh replaces in-flight loads") + func explicitRefreshReplacesInFlightLoads() async throws { + let staleKwt = KwtHostInventory( + projects: [], + projectsWarning: "stale" + ) + let freshKwt = KwtHostInventory(projects: []) + let staleTmux = DiscoveredTmuxSession( + name: "stale", + windowCount: 1, + createdAt: nil, + managed: false + ) + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let firstKwtLoad = AsyncGate() + let firstTmuxLoad = AsyncGate() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = kwtCount.load() + kwtCount.withLock { $0 += 1 } + if attempt == 0 { + await firstKwtLoad.wait() + return staleKwt + } + return freshKwt + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = tmuxCount.load() + tmuxCount.withLock { $0 += 1 } + if attempt == 0 { + await firstTmuxLoad.wait() + return .success([staleTmux]) + } + return .success([]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: true + ) + await waitUntil { + kwtCount.load() == 1 && tmuxCount.load() == 1 + } + + store.refreshAll(for: subscriberID) + await waitUntilMainActor { + kwtCount.load() == 2 && tmuxCount.load() == 2 + } + firstKwtLoad.open() + firstTmuxLoad.open() + + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.inventory == freshKwt + && store.snapshot.tmuxByHost[.local]?.sessions == [] + } + try await Task.sleep(for: .milliseconds(20)) + #expect(store.snapshot.kwtByHost[.local]?.inventory == freshKwt) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == []) + } + + @Test("subscribers for one endpoint share one load per lane") + func subscribersShareLoads() async throws { + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let kwtGate = AsyncGate() + let tmuxGate = AsyncGate() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + await kwtGate.wait() + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxCount.withLock { $0 += 1 } + await tmuxGate.wait() + return .success([]) + }, + sleep: { duration in + try await Task.sleep(for: duration) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let registration = WorkspaceInventoryStore.HostRegistration( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + ) + + store.updateSubscriber( + id: UUID(), + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + store.updateSubscriber( + id: UUID(), + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + + await waitUntil { + kwtCount.load() == 1 && tmuxCount.load() == 1 + } + kwtGate.open() + tmuxGate.open() + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + && store.snapshot.tmuxByHost[.local]?.isFresh == true + } + + store.updateSubscriber( + id: UUID(), + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + try await Task.sleep(for: .milliseconds(20)) + + #expect(kwtCount.load() == 1) + #expect(tmuxCount.load() == 1) + } + + @Test("a new subscriber restarts loads cancelled with the last subscriber") + func cancelledLastSubscriberLoadsRestart() async { + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let kwtGate = AsyncGate() + let tmuxGate = AsyncGate() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + await kwtGate.wait() + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxCount.withLock { $0 += 1 } + await tmuxGate.wait() + return .success([]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let registration = WorkspaceInventoryStore.HostRegistration( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + ) + let firstSubscriberID = UUID() + store.updateSubscriber( + id: firstSubscriberID, + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + await waitUntil { + kwtCount.load() == 1 && tmuxCount.load() == 1 + } + + store.removeSubscriber(id: firstSubscriberID) + store.updateSubscriber( + id: UUID(), + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + + await waitUntil(timeout: .seconds(1)) { + kwtCount.load() == 2 && tmuxCount.load() == 2 + } + kwtGate.open() + tmuxGate.open() + } + + @Test("a new subscriber refreshes completed and failed cache entries") + func newSubscriberRefreshesInactiveCache() async { + let kwtCount = LockedValue(0) + let tmuxCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = tmuxCount.load() + tmuxCount.withLock { $0 += 1 } + return attempt == 0 + ? .failure(.notFound(shell: "test")) + : .success([]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let registration = WorkspaceInventoryStore.HostRegistration( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + ) + let firstSubscriberID = UUID() + store.updateSubscriber( + id: firstSubscriberID, + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + await waitUntilMainActor { + guard store.snapshot.kwtByHost[.local]?.isFresh == true, + let tmux = store.snapshot.tmuxByHost[.local] + else { return false } + guard case .failed = tmux.state else { return false } + return true + } + + store.removeSubscriber(id: firstSubscriberID) + store.updateSubscriber( + id: UUID(), + registrations: [registration], + wantsKwt: true, + wantsTmux: true + ) + await waitUntilMainActor { + kwtCount.load() == 2 && tmuxCount.load() == 2 + } + + #expect(kwtCount.load() == 2) + #expect(tmuxCount.load() == 2) + } + + @Test("partial project failure stays merged in the shared cache") + func partialProjectFailureRetainsSharedWorktrees() async { + let project = KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let retained = KwtWorktreeRecord( + path: "/test/repository-retained", + branch: "feature/retained", + commitHash: "retained", + isMain: false, + createdAt: nil, + generation: "11111111111111111111111111111111", + repository: project.repository, + sessionName: "retained", + tmuxSocketName: nil + ) + let refreshed = KwtWorktreeRecord( + path: "/test/repository-refreshed", + branch: "feature/refreshed", + commitHash: "refreshed", + isMain: false, + createdAt: nil, + generation: "22222222222222222222222222222222", + repository: project.repository, + sessionName: "refreshed", + tmuxSocketName: nil + ) + let complete = KwtHostInventory(projects: [ + KwtProjectInventory( + project: project, + worktrees: [retained, refreshed], + warning: nil + ), + ]) + let partial = KwtHostInventory(projects: [ + KwtProjectInventory( + project: project, + worktrees: [refreshed], + warning: "inventory unavailable" + ), + ]) + let loadCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = loadCount.load() + loadCount.withLock { $0 += 1 } + return attempt == 0 ? complete : partial + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.inventory == complete + } + + store.refreshKwt(for: subscriberID) + + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventoryRevision != 1 + } + #expect(Set( + store.snapshot.kwtByHost[.local]?.inventory?.projects + .first?.worktrees.map(\.path) ?? [] + ) == [retained.path, refreshed.path]) + } + + @Test("a failed refresh retains cached rows and revokes freshness") + func failureRetainsCache() async { + enum RefreshFailure: Error { + case failed + } + let kwtCount = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = kwtCount.load() + kwtCount.withLock { $0 += 1 } + if attempt == 0 { + return KwtHostInventory(projects: []) + } + throw RefreshFailure.failed + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + let cachedRevision = store.snapshot.kwtByHost[.local]? + .inventoryRevision + + store.refreshKwt(for: subscriberID) + + await waitUntilMainActor { + guard let entry = store.snapshot.kwtByHost[.local] else { + return false + } + guard case .failed = entry.state else { return false } + return true + } + guard let entry = store.snapshot.kwtByHost[.local] else { + Issue.record("KWT refresh did not publish a terminal entry") + return + } + #expect(entry.inventory != nil) + #expect(entry.inventoryRevision == cachedRevision) + #expect(entry.isFresh == false) + } + + @Test("stale scene probe publication yields to a newer shared refresh") + func staleProbePublicationYieldsToNewerRefresh() async throws { + let firstLoad = AsyncGate() + let secondLoad = AsyncGate() + let loadCount = LockedValue(0) + let fresh = DiscoveredTmuxSession( + name: "fresh", + windowCount: 1, + createdAt: nil, + managed: false + ) + let probed = DiscoveredTmuxSession( + name: "probed", + windowCount: 1, + createdAt: nil, + managed: false + ) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = loadCount.load() + loadCount.withLock { $0 += 1 } + if attempt == 0 { + await firstLoad.wait() + } else { + await secondLoad.wait() + } + return .success([fresh]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + defer { + firstLoad.open() + secondLoad.open() + store.removeSubscriber(id: subscriberID) + } + + let staleEpoch = store.tmuxRefreshEpoch(on: .local) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: false, + wantsTmux: true + ) + store.publishTmuxSessions([probed], on: .local, epoch: staleEpoch) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == nil) + + firstLoad.open() + await waitUntilMainActor { + store.snapshot.tmuxByHost[.local]?.sessions == [fresh] + } + #expect(store.snapshot.tmuxByHost[.local]?.isFresh == true) + + store.refreshTmux(for: subscriberID) + let currentEpoch = store.tmuxRefreshEpoch(on: .local) + await secondLoad.waitUntilWaiting() + store.publishTmuxSessions([probed], on: .local, epoch: currentEpoch) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == [probed]) + #expect(store.snapshot.tmuxByHost[.local]?.isFresh == true) + + secondLoad.open() + try await Task.sleep(for: .milliseconds(20)) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == [probed]) + } + + @Test("mutation end replaces in-flight shared tmux inventory") + func mutationEndReplacesInFlightTmux() async throws { + let coordinator = WorktreeMutationCoordinator() + let firstLoad = AsyncGate() + let loadCount = LockedValue(0) + let hostID = UUID() + let stale = DiscoveredTmuxSession( + name: "stale", + windowCount: 1, + createdAt: nil, + managed: false + ) + let fresh = DiscoveredTmuxSession( + name: "fresh", + windowCount: 1, + createdAt: nil, + managed: false + ) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = loadCount.load() + loadCount.withLock { $0 += 1 } + guard attempt == 0 else { return .success([fresh]) } + await firstLoad.wait() + return .success([stale]) + }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { + firstLoad.open() + store.removeSubscriber(id: subscriberID) + } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: true + ) + await firstLoad.waitUntilWaiting() + + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: "example/repository" + )) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: .init( + hostID: hostID, + host: .local, + epoch: store.kwtMutationEpoch(on: .local) + ) + ) + coordinator.release( + hostID: hostID, + projectIdentity: "example/repository" + ) + firstLoad.open() + + await waitUntilMainActor { + store.snapshot.tmuxByHost[.local]?.sessions == [fresh] + } + #expect(loadCount.load() == 2) + #expect(store.snapshot.tmuxByHost[.local]?.isFresh == true) + try await Task.sleep(for: .milliseconds(20)) + #expect(store.snapshot.tmuxByHost[.local]?.sessions == [fresh]) + } + + @Test("stale mutation publication yields to a newer mutation") + func staleMutationPublicationYieldsToNewerMutation() async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let project = KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let registered = KwtHostInventory(projects: [KwtProjectInventory( + project: project, + worktrees: [], + warning: nil + )]) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect(loadCount.load() == 1) + + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "x")) + let staleEpoch = store.kwtMutationEpoch(on: .local) + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "y")) + let currentEpoch = store.kwtMutationEpoch(on: .local) + #expect(staleEpoch != currentEpoch) + + store.publishKwtInventory( + registered, + on: .local, + mutation: .init(hostID: hostID, host: .local, epoch: currentEpoch) + ) + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == registered.projects + ) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: .init(hostID: hostID, host: .local, epoch: staleEpoch) + ) + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == registered.projects + ) + + coordinator.release(hostID: hostID, projectIdentity: "y") + coordinator.release(hostID: hostID, projectIdentity: "x") + await waitUntilMainActor { loadCount.load() == 2 } + #expect(loadCount.load() == 2) + } + + @Test("warning-bearing publication leaves the fence-end refresh pending") + func warningPublicationLeavesFenceEndRefreshPending() async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: "example/repository" + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + store.publishKwtInventory( + KwtHostInventory( + projects: [], + projectsWarning: "kwt list failed" + ), + on: .local, + mutation: .init( + hostID: hostID, + host: .local, + epoch: store.kwtMutationEpoch(on: .local) + ) + ) + coordinator.release( + hostID: hostID, + projectIdentity: "example/repository" + ) + + await waitUntilMainActor { loadCount.load() == 1 } + #expect(loadCount.load() == 1) + } + + @Test("legacy-empty repository identity keeps removed worktrees hidden") + func legacyEmptyIdentityKeepsRemovedWorktreesHidden() async { + let repository = "example/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/removed", + branch: "feature/removed", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "removed-generation", + repository: "", + sessionName: "kwt-feature-removed" + ) + let legacy = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: "", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [worktree], + warning: nil + )]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return legacy + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: worktree.generation ?? "" + )] + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + ) + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory? + .projects.first?.worktrees.isEmpty == true + ) + } + + @Test( + "registration during a mutation forces the fence-end reload", + arguments: [true, false] + ) + func registrationDuringMutationForcesReload( + publishesBeforeRegistration: Bool + ) async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "x")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + let mutation = WorkspaceInventoryStore.MutationPublication( + hostID: hostID, + host: .local, + epoch: store.kwtMutationEpoch(on: .local) + ) + let publish = { + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: mutation + ) + } + if publishesBeforeRegistration { + publish() + } + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "y", + projectPath: "/test/y" + ) + if !publishesBeforeRegistration { + publish() + } + coordinator.release(hostID: hostID, projectIdentity: "x") + + await waitUntilMainActor { loadCount.load() == 1 } + #expect(loadCount.load() == 1) + } + + @Test("mutation publication for another endpoint is rejected") + func mutationPublicationForAnotherEndpointIsRejected() { + let coordinator = WorktreeMutationCoordinator() + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "x")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + let registered = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [], + warning: nil + )]) + store.publishKwtInventory( + registered, + on: .local, + mutation: .init( + hostID: hostID, + host: .ssh(SSHHostInfo( + user: "test", + hostname: "example.invalid", + port: nil, + platform: .posix + )), + epoch: store.kwtMutationEpoch(on: .local) + ) + ) + #expect(store.snapshot.kwtByHost[.local]?.inventory == nil) + coordinator.release(hostID: hostID, projectIdentity: "x") + } + + @Test("legacy-empty repository identity keeps a removed project hidden") + func legacyEmptyIdentityKeepsRemovedProjectHidden() async { + let repository = "example/repository" + let path = "/test/repository" + let registered = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: repository, + name: "Repository", + path: path, + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [], + warning: nil + )]) + let legacy = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: "", + name: "Repository", + path: path, + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [], + warning: nil + )]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return legacy + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.publishKwtInventory(registered, on: .local, mutation: nil) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removesProject: true + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: repository, + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 3 + && store.snapshot.kwtByHost[.local]?.inventory?.projects + == legacy.projects + } + } + + private func legacyProject( + name: String, + path: String, + repository: String = "" + ) -> KwtProjectInventory { + KwtProjectInventory( + project: KwtProjectRecord( + repository: repository, + name: name, + path: path, + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [], + warning: nil + ) + } + + @Test("an empty-identity project tombstone hides only its own path") + func emptyIdentityTombstoneHidesOnlyItsOwnPath() async { + var removed = legacyProject(name: "Removed", path: "/test/removed") + removed.project.registrationFingerprint = "" + let kept = legacyProject(name: "Kept", path: "/test/kept") + let inventory = KwtHostInventory(projects: [removed, kept]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: "", + removesProject: true, + projectPath: removed.project.path + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects == [kept] + ) + } + + @Test("re-registration clears a project tombstone by path") + func reregistrationClearsProjectTombstoneByPath() async { + let path = "/test/repository" + let legacy = KwtHostInventory(projects: [ + legacyProject(name: "Repository", path: path), + ]) + var canonical = KwtHostInventory(projects: [ + legacyProject( + name: "Repository", + path: path, + repository: "example/repository" + ), + ]) + canonical.projects[0].project.registrationFingerprint = "" + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { [canonical] _ in + loadCount.withLock { $0 += 1 } + return canonical + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.publishKwtInventory(legacy, on: .local, mutation: nil) + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: "", + removesProject: true, + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "example/repository", + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory?.projects + == canonical.projects + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == canonical.projects + ) + } + + @Test("a mutation ended while publishing still reloads at fence end") + func mutationEndedDuringPublicationStillReloads() async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let secondLoad = AsyncGate() + let hostID = UUID() + let removed = KwtHostInventory(projects: [ + legacyProject(name: "X", path: "/test/x", repository: "x"), + ]) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + if loadCount.load() > 1 { + await secondLoad.wait() + } + return removed + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { + secondLoad.open() + store.removeSubscriber(id: subscriberID) + } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "x")) + coordinator.prepareRemoval( + hostID: hostID, + projectIdentity: "x", + worktrees: [], + presentationTargets: [] + ) + coordinator.quarantineProjectRemoval( + hostID: hostID, + projectIdentity: "x", + projectPath: "/test/x", + host: .local + ) + let released = LockedValue(false) + let cancellable = store.snapshotPublisher.sink { snapshot in + guard !released.load(), + snapshot.kwtByHost[.local]?.isFresh == true else { return } + released.store(true) + coordinator.release( + hostID: hostID, + projectIdentity: "x", + removesProject: true, + allowsRemovalRestoration: false, + projectPath: "/test/x" + ) + } + defer { cancellable.cancel() } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + + await waitUntilMainActor { loadCount.load() == 2 } + #expect(coordinator.scopes.isEmpty) + // The tombstone applied inside the publication must survive it. + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + secondLoad.open() + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + && store.snapshot.kwtByHost[.local]?.inventory?.projects + .isEmpty == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + } + + @Test("a provisional publication revokes cached freshness") + func provisionalPublicationRevokesFreshness() { + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: nil + ) + #expect(store.snapshot.kwtByHost[.local]?.isFresh == true) + + let provisional = KwtHostInventory(projects: [ + legacyProject(name: "X", path: "/test/x", repository: "x"), + ]) + store.publishKwtInventory( + provisional, + on: .local, + mutation: nil, + recordsSuccessfulLoad: false + ) + #expect(store.snapshot.kwtByHost[.local]?.isFresh == false) + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == provisional.projects + ) + } + + @Test("registration discards a load that started before it") + func registrationDiscardsEarlierLoad() async { + let firstLoad = AsyncGate() + let loadCount = LockedValue(0) + let hostID = UUID() + let registered = KwtHostInventory(projects: [ + legacyProject(name: "Y", path: "/test/y", repository: "y"), + ]) + let coordinator = WorktreeMutationCoordinator() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + if loadCount.load() == 1 { + await firstLoad.wait() + return KwtHostInventory(projects: []) + } + return registered + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { + firstLoad.open() + store.removeSubscriber(id: subscriberID) + } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await firstLoad.waitUntilWaiting() + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "y", + projectPath: "/test/y" + ) + firstLoad.open() + + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + && store.snapshot.kwtByHost[.local]?.inventory?.projects + == registered.projects + } + #expect(loadCount.load() == 2) + } + + @Test("registration releases legacy-identity worktree tombstones") + func registrationReleasesLegacyWorktreeTombstones() async { + let path = "/test/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/feature", + branch: "feature", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "feature-generation", + repository: "", + sessionName: "kwt-feature" + ) + var legacy = legacyProject(name: "Repository", path: path) + legacy.worktrees = [worktree] + let inventory = KwtHostInventory(projects: [legacy]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: "", + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: worktree.generation ?? "" + )], + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees.isEmpty == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "example/repository", + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory?.projects + .first?.worktrees == [worktree] + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees == [worktree] + ) + } + + @Test("project tombstones compare normalized paths") + func projectTombstonesCompareNormalizedPaths() async { + let canonical = KwtHostInventory(projects: [ + legacyProject( + name: "Repository", + path: "/test/repository", + repository: "example/repository" + ), + ]) + let legacy = KwtHostInventory(projects: [ + legacyProject(name: "Repository", path: "/test/repository/"), + ]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return legacy + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.publishKwtInventory(canonical, on: .local, mutation: nil) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: "example/repository" + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: "example/repository", + removesProject: true, + projectPath: "/test/repository/./" + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.isEmpty + == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "", + projectPath: "/test/./repository" + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory?.projects + == legacy.projects + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == legacy.projects + ) + } + + @Test("registration releases only the registered legacy project's tombstones") + func registrationReleasesOnlyItsLegacyTombstones() async { + func worktree(_ path: String) -> KwtWorktreeRecord { + KwtWorktreeRecord( + path: path, + branch: "feature", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: nil, + repository: "", + sessionName: "kwt-feature" + ) + } + var first = legacyProject(name: "First", path: "/test/first") + first.worktrees = [worktree("/test/first/feature")] + var second = legacyProject(name: "Second", path: "/test/second") + second.worktrees = [worktree("/test/second/feature")] + let inventory = KwtHostInventory(projects: [first, second]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + for project in [first, second] { + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "")) + let loads = loadCount.load() + coordinator.release( + hostID: hostID, + projectIdentity: "", + removalTombstones: [KwtWorktreeIdentity( + path: project.worktrees[0].path, + generation: "" + )], + projectPath: project.project.path + ) + await waitUntilMainActor { + loadCount.load() == loads + 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + .allSatisfy { $0.worktrees.isEmpty } == true + ) + + let loads = loadCount.load() + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "", + projectPath: first.project.path + ) + await waitUntilMainActor { + loadCount.load() == loads + 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + let projects = store.snapshot.kwtByHost[.local]?.inventory?.projects + #expect(projects?.first?.worktrees == first.worktrees) + #expect(projects?.last?.worktrees.isEmpty == true) + } + + @Test("invalidation during provisioning skips the stale load") + func invalidationDuringProvisioningSkipsStaleLoad() async throws { + let provisioning = AsyncGate() + let provisionCount = LockedValue(0) + let loadCount = LockedValue(0) + let commandHost = CommandHost.ssh(.init( + user: "test", + hostname: "example.invalid", + port: nil, + platform: .posix + )) + let configuredHost = SSHHost( + configKey: "test-linux", + name: "Test Linux", + platform: .linux, + sshDestination: "test@example.invalid" + ) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in + provisionCount.withLock { $0 += 1 } + if provisionCount.load() == 1 { + await provisioning.wait() + } + }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + defer { + provisioning.open() + store.removeSubscriber(id: subscriberID) + } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: commandHost, + provisioningHost: configuredHost + )], + wantsKwt: true, + wantsTmux: false + ) + await provisioning.waitUntilWaiting() + + store.refreshKwt(for: subscriberID) + await waitUntilMainActor { + store.snapshot.kwtByHost[commandHost]?.isFresh == true + } + #expect(loadCount.load() == 1) + + provisioning.open() + try await Task.sleep(for: .milliseconds(50)) + #expect(loadCount.load() == 1) + } + + @Test("registration clears worktree tombstones of an earlier identity") + func registrationClearsEarlierIdentityWorktreeTombstones() async { + let path = "/test/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/feature", + branch: "feature", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: nil, + repository: "", + sessionName: "kwt-feature" + ) + var legacy = legacyProject(name: "Repository", path: path) + legacy.worktrees = [worktree] + let inventory = KwtHostInventory(projects: [legacy]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "old")) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: "old", + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: "" + )], + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees.isEmpty == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: "new", + projectPath: path + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.inventory?.projects + .first?.worktrees == [worktree] + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees == [worktree] + ) + } + + @Test("a later subscriber loads after a provisional publication") + func laterSubscriberLoadsAfterProvisionalPublication() async { + let loadCount = LockedValue(0) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: nil + ) + store.publishKwtInventory( + KwtHostInventory(projects: []), + on: .local, + mutation: nil, + recordsSuccessfulLoad: false + ) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: UUID(), + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect(loadCount.load() == 1) + } + + @Test( + "authoritative registration clears project and worktree removals", + arguments: ["", "old-registration"] + ) + func authoritativeRegistrationClearsRemovalTombstones(cachedFingerprint: String) async { + let repository = "example/repository" + let path = "/test/repository" + let worktree = KwtWorktreeRecord( + path: path + "/feature", branch: "feature", commitHash: "abc123", + isMain: false, createdAt: nil, generation: "existing-worktree", + repository: repository, sessionName: "kwt-feature" + ) + let inventory = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: repository, + name: "Repository", + path: path, + lastTouched: nil, + registrationFingerprint: "new-registration" + ), + worktrees: [worktree], + warning: nil + )]) + let coordinator = WorktreeMutationCoordinator() + let loadGate = AsyncGate() + let hostID = UUID() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + await loadGate.wait() + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + if !cachedFingerprint.isEmpty { + var cached = inventory + cached.projects[0].project.registrationFingerprint = cachedFingerprint + store.publishKwtInventory(cached, on: .local, mutation: nil) + } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: repository)) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, commandHost: .local, provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, projectIdentity: repository, + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: "existing-worktree" + )], + removesProject: true, projectPath: path + ) + #expect(store.projectRemovalTombstones(on: .local).first? + .registrationFingerprint == cachedFingerprint) + + store.publishKwtInventory( + inventory, on: .local, mutation: nil, recordsSuccessfulLoad: false + ) + #expect(!store.projectRemovalTombstones(on: .local).isEmpty) + #expect(store.snapshot.kwtByHost[.local]?.inventory?.projects.flatMap(\.worktrees) + .isEmpty == true) + + store.refreshKwt(for: subscriberID) + loadGate.open() + await waitUntilMainActor { store.snapshot.kwtByHost[.local]?.isFresh == true } + #expect(store.snapshot.kwtByHost[.local]?.inventory?.projects == inventory.projects) + #expect(store.projectRemovalTombstones(on: .local).isEmpty) + } + + @Test( + "mutation completion follows its endpoint after the originating host disappears", + arguments: [false, true] + ) + func mutationCompletionRetainsEndpoint(retargetsHost: Bool) async { + let coordinator = WorktreeMutationCoordinator() + var project = legacyProject( + name: "Repository", + path: "/test/repository", + repository: "example/repository" + ) + let worktree = KwtWorktreeRecord( + path: "/test/repository/feature", branch: "feature", commitHash: "abc123", + isMain: false, createdAt: nil, generation: "existing-worktree", + repository: "example/repository", sessionName: "kwt-feature" + ) + project.worktrees = [worktree] + let inventory = KwtHostInventory(projects: [project]) + let kwtLoads = LockedValue(0) + let tmuxLoads = LockedValue(0) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + kwtLoads.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxLoads.withLock { $0 += 1 } + return .success([]) + }, + mutationCoordinator: coordinator + ) + let originID = UUID() + let aliasID = UUID() + let hostID = UUID() + let otherHost = CommandHost.ssh(SSHHostInfo( + user: "test", + hostname: "example.invalid", + port: nil, + platform: .posix + )) + defer { + store.removeSubscriber(id: originID) + store.removeSubscriber(id: aliasID) + } + store.publishKwtInventory(inventory, on: .local, mutation: nil) + for (subscriberID, registeredHostID) in [(originID, hostID), (aliasID, UUID())] { + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: registeredHostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, wantsTmux: true + ) + } + await waitUntilMainActor { store.snapshot.tmuxByHost[.local]?.isFresh == true } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "example/repository")) + store.removeSubscriber(id: originID) + if retargetsHost { + store.updateSubscriber( + id: originID, + registrations: [.init( + hostID: hostID, + commandHost: otherHost, + provisioningHost: nil + )], + wantsKwt: false, wantsTmux: false + ) + } + + coordinator.release( + hostID: hostID, projectIdentity: "example/repository", + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: "existing-worktree" + )], + projectPath: "/test/repository" + ) + + #expect(store.snapshot.kwtByHost[.local]?.inventory?.projects.first?.worktrees + .isEmpty == true) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true && tmuxLoads.load() == 2 + } + #expect(kwtLoads.load() == 1) + #expect(store.removalTombstones(on: otherHost).isEmpty) + } + + @Test("a repository registered again elsewhere escapes its tombstone") + func reregisteredRepositoryEscapesTombstone() async { + let repository = "example/repository" + let removed = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: repository, + name: "Repository", + path: "/test/old", + lastTouched: nil, + registrationFingerprint: "old-registration" + ), + worktrees: [], + warning: nil + )]) + let reregistered = KwtHostInventory(projects: [KwtProjectInventory( + project: KwtProjectRecord( + repository: repository, + name: "Repository", + path: "/test/new", + lastTouched: nil, + registrationFingerprint: "new-registration" + ), + worktrees: [], + warning: nil + )]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return reregistered + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.publishKwtInventory(removed, on: .local, mutation: nil) + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removesProject: true, + projectPath: "/test/old" + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + == reregistered.projects + ) + } + + @Test("registering a repository elsewhere keeps its tombstones") + func registeringRepositoryElsewhereKeepsTombstones() async { + let repository = "example/repository" + let worktree = KwtWorktreeRecord( + path: "/test/repository/feature", + branch: "feature", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: nil, + repository: repository, + sessionName: "kwt-feature" + ) + var project = legacyProject( + name: "Repository", + path: "/test/repository", + repository: repository + ) + project.worktrees = [worktree] + let inventory = KwtHostInventory(projects: [project]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removalTombstones: [KwtWorktreeIdentity( + path: worktree.path, + generation: "" + )], + projectPath: project.project.path + ) + await waitUntilMainActor { + loadCount.load() == 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees.isEmpty == true + ) + + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: repository, + projectPath: "/test/elsewhere" + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees.isEmpty == true + ) + } + + @Test("re-registering the same subscriber does not retry a failed load") + func reregisteringSubscriberDoesNotRetryFailedLoad() async throws { + enum LoadFailure: Error { + case failed + } + let loadCount = LockedValue(0) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + throw LoadFailure.failed + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let subscriberID = UUID() + let hostID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + let registrations: [WorkspaceInventoryStore.HostRegistration] = [ + .init(hostID: hostID, commandHost: .local, provisioningHost: nil), + ] + store.updateSubscriber( + id: subscriberID, + registrations: registrations, + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + guard let entry = store.snapshot.kwtByHost[.local] else { + return false + } + if case .failed = entry.state { + return true + } + return false + } + #expect(loadCount.load() == 1) + + for _ in 0 ..< 3 { + store.updateSubscriber( + id: subscriberID, + registrations: registrations, + wantsKwt: true, + wantsTmux: false + ) + } + try await Task.sleep(for: .milliseconds(20)) + #expect(loadCount.load() == 1) + + store.updateSubscriber( + id: subscriberID, + registrations: registrations, + wantsKwt: true, + wantsTmux: true + ) + try await Task.sleep(for: .milliseconds(20)) + #expect(loadCount.load() == 1) + } + + @Test("re-registering one of two same-repository projects restores only it") + func reregisteringOneOfTwoSameRepositoryProjectsRestoresOnlyIt() async { + let repository = "example/repository" + func project(_ path: String) -> KwtProjectInventory { + var item = legacyProject( + name: "Repository", + path: path, + repository: repository + ) + item.worktrees = [KwtWorktreeRecord( + path: "\(path)/feature", + branch: "feature", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: nil, + repository: repository, + sessionName: "kwt-feature" + )] + return item + } + let first = project("/test/first") + let second = project("/test/second") + let inventory = KwtHostInventory(projects: [first, second]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + for item in [first, second] { + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + let loads = loadCount.load() + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removalTombstones: [KwtWorktreeIdentity( + path: item.worktrees[0].path, + generation: "" + )], + projectPath: item.project.path + ) + await waitUntilMainActor { + loadCount.load() == loads + 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects + .allSatisfy { $0.worktrees.isEmpty } == true + ) + + let loads = loadCount.load() + coordinator.noteProjectRegistration( + hostID: hostID, + projectIdentity: repository, + projectPath: first.project.path + ) + await waitUntilMainActor { + loadCount.load() == loads + 1 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + let projects = store.snapshot.kwtByHost[.local]?.inventory?.projects + #expect(projects?.first?.worktrees == first.worktrees) + #expect(projects?.last?.worktrees.isEmpty == true) + } + + @Test("removing one of two same-repository projects tombstones its path") + func removingOneOfTwoSameRepositoryProjectsTombstonesItsPath() async { + let repository = "example/repository" + let first = legacyProject( + name: "First", + path: "/test/first", + repository: repository + ) + var second = legacyProject( + name: "Second", + path: "/test/second", + repository: repository + ) + second.project.registrationFingerprint = "second-registration" + let inventory = KwtHostInventory(projects: [first, second]) + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect(coordinator.acquire( + hostID: hostID, + projectIdentity: repository + )) + coordinator.release( + hostID: hostID, + projectIdentity: repository, + removesProject: true, + projectPath: second.project.path + ) + await waitUntilMainActor { + loadCount.load() == 2 + && store.snapshot.kwtByHost[.local]?.isFresh == true + } + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects == [first] + ) + } + + @Test("a parked quarantine does not block another mutation's reload") + func parkedQuarantineDoesNotBlockAnotherMutationsReload() async { + let coordinator = WorktreeMutationCoordinator() + let loadCount = LockedValue(0) + let hostID = UUID() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in + loadCount.withLock { $0 += 1 } + return KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + defer { store.removeSubscriber(id: subscriberID) } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "q")) + coordinator.prepareRemoval( + hostID: hostID, + projectIdentity: "q", + worktrees: [], + presentationTargets: [] + ) + coordinator.quarantineProjectRemoval( + hostID: hostID, + projectIdentity: "q", + projectPath: "/test/q", + host: .local + ) + store.updateSubscriber( + id: subscriberID, + registrations: [.init( + hostID: hostID, + commandHost: .local, + provisioningHost: nil + )], + wantsKwt: true, + wantsTmux: false + ) + await waitUntilMainActor { + store.snapshot.kwtByHost[.local]?.isFresh == true + } + let initialLoads = loadCount.load() + + // A mutation that ends without an authoritative publication relies + // on the fence-end reload; the parked quarantine must not block it. + #expect(coordinator.acquire(hostID: hostID, projectIdentity: "x")) + coordinator.release(hostID: hostID, projectIdentity: "x") + + await waitUntilMainActor { loadCount.load() == initialLoads + 1 } + #expect(loadCount.load() == initialLoads + 1) + } +} diff --git a/Tests/App/WorkspaceSharedInventoryTests.swift b/Tests/App/WorkspaceSharedInventoryTests.swift new file mode 100644 index 00000000..11dfe545 --- /dev/null +++ b/Tests/App/WorkspaceSharedInventoryTests.swift @@ -0,0 +1,1205 @@ +import Combine +import Foundation +import GhosthubPersistence +import GhosthubSettings +import GhosthubTransport +import GhosthubWorkspace +import Testing +@testable import GhosthubApp + +@Suite("Shared workspace inventory", .serialized) +@MainActor +struct WorkspaceSharedInventoryTests { + @Test("endpoint aliases each receive the shared cached result") + func endpointAliasesReceiveSharedResult() async throws { + let localID = UUID() + let firstRemoteID = UUID() + let secondRemoteID = UUID() + let project = KwtProjectInventory( + project: KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ), + worktrees: [], + warning: nil + ) + let remoteLoads = LockedValue(0) + let snapshot = WorkspaceSnapshot( + hosts: [ + HostSummary( + id: localID, + configKey: "local", + name: "This Mac", + kind: .selfHost, + platform: .macOS, + preferredTransport: .local, + decodedConnectionState: .local + ), + HostSummary( + id: firstRemoteID, + configKey: "alias-a", + name: "Alias A", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + HostSummary( + id: secondRemoteID, + configKey: "alias-b", + name: "Alias B", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + ], + projects: [], + worktrees: [] + ) + let store = WorkspaceInventoryStore( + kwtLoader: { host in + guard host.isRemote else { + return KwtHostInventory(projects: []) + } + remoteLoads.withLock { $0 += 1 } + return KwtHostInventory(projects: [project]) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let model = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: localID, + snapshot: snapshot, + workspaceInventoryStore: store + ) + + model.startKwtInventory() + await waitUntilMainActor { + Set(model.snapshot.projects.map(\.hostID)) + == [firstRemoteID, secondRemoteID] + } + + #expect(remoteLoads.load() == 1) + await model.shutdown() + } + + @Test("mutation publication updates every endpoint alias") + func mutationPublicationUpdatesEveryEndpointAlias() async throws { + let localID = UUID() + let firstRemoteID = UUID() + let secondRemoteID = UUID() + let projectRecord = KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let initialInventory = KwtHostInventory(projects: [ + KwtProjectInventory( + project: projectRecord, + worktrees: [], + warning: nil + ), + ]) + let refreshedInventory = KwtHostInventory(projects: [ + KwtProjectInventory( + project: projectRecord, + worktrees: [KwtWorktreeRecord( + path: "/test/repository/created", + branch: "feature/created", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "created-generation", + repository: projectRecord.repository, + sessionName: "kwt-feature-created" + )], + warning: nil + ), + ]) + let snapshot = WorkspaceSnapshot( + hosts: [ + HostSummary( + id: localID, + configKey: "local", + name: "This Mac", + kind: .selfHost, + platform: .macOS, + preferredTransport: .local, + decodedConnectionState: .local + ), + HostSummary( + id: firstRemoteID, + configKey: "alias-a", + name: "Alias A", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + HostSummary( + id: secondRemoteID, + configKey: "alias-b", + name: "Alias B", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + ], + projects: [], + worktrees: [] + ) + let coordinator = WorktreeMutationCoordinator() + let remoteLoads = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { host in + guard host.isRemote else { + return KwtHostInventory(projects: []) + } + remoteLoads.withLock { $0 += 1 } + return initialInventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: localID, + snapshot: snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in refreshedInventory }, + kwtWorktreeCreator: { _, _, _ in }, + worktreeMutationCoordinator: coordinator + ) + + model.startKwtInventory() + await waitUntilMainActor { + model.snapshot.projects.filter { + $0.scopedKey == projectRecord.repository + }.count == 2 + } + let firstProject = try #require(model.snapshot.projects.first { + $0.hostID == firstRemoteID + }) + + try await model.createWorktree(WorktreeCreateRequest( + projectID: firstProject.id, + branchName: "feature/created", + createsBranch: true + )) + + #expect(Set(model.snapshot.worktrees.filter { + $0.branch == "feature/created" + }.map(\.hostID)) == [firstRemoteID, secondRemoteID]) + try await Task.sleep(for: .milliseconds(20)) + #expect(remoteLoads.load() == 1) + await model.shutdown() + } + + @Test("mutation publication reaches a second scene without a reload") + func mutationPublicationReachesSecondSceneWithoutReload() async throws { + let localID = UUID() + let projectRecord = KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/test/repository", + lastTouched: nil, + registrationFingerprint: "test-registration" + ) + let initialInventory = KwtHostInventory(projects: [ + KwtProjectInventory( + project: projectRecord, + worktrees: [], + warning: nil + ), + ]) + let refreshedInventory = KwtHostInventory(projects: [ + KwtProjectInventory( + project: projectRecord, + worktrees: [KwtWorktreeRecord( + path: "/test/repository/created", + branch: "feature/created", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "created-generation", + repository: projectRecord.repository, + sessionName: "kwt-feature-created" + )], + warning: nil + ), + ]) + let snapshot = WorkspaceSnapshot( + hosts: [HostSummary( + id: localID, + configKey: "local", + name: "This Mac", + kind: .selfHost, + platform: .macOS, + preferredTransport: .local, + decodedConnectionState: .local + )], + projects: [], + worktrees: [] + ) + let coordinator = WorktreeMutationCoordinator() + let loads = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + loads.withLock { $0 += 1 } + return initialInventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let first = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: localID, + snapshot: snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in refreshedInventory }, + kwtWorktreeCreator: { _, _, _ in }, + worktreeMutationCoordinator: coordinator + ) + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: localID, + snapshot: snapshot, + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + + first.startKwtInventory() + second.startKwtInventory() + await waitUntilMainActor { + first.snapshot.projects.contains { + $0.scopedKey == projectRecord.repository + } && second.snapshot.projects.contains { + $0.scopedKey == projectRecord.repository + } + } + #expect(loads.load() == 1) + let project = try #require(first.snapshot.projects.first { + $0.scopedKey == projectRecord.repository + }) + + try await first.createWorktree(WorktreeCreateRequest( + projectID: project.id, + branchName: "feature/created", + createsBranch: true + )) + + #expect(first.snapshot.worktrees.contains { + $0.branch == "feature/created" + }) + await waitUntilMainActor { + second.snapshot.worktrees.contains { + $0.branch == "feature/created" + } + } + try await Task.sleep(for: .milliseconds(20)) + #expect(loads.load() == 1) + #expect(first.inventoryRefreshProgress.kwtCompleted) + #expect(second.inventoryRefreshProgress.kwtCompleted) + await first.shutdown() + await second.shutdown() + } + + @Test("registration on an aliased host announces once") + func registrationOnAliasedHostAnnouncesOnce() async throws { + let localID = UUID() + let firstRemoteID = UUID() + let secondRemoteID = UUID() + let snapshot = WorkspaceSnapshot( + hosts: [ + HostSummary( + id: localID, + configKey: "local", + name: "This Mac", + kind: .selfHost, + platform: .macOS, + preferredTransport: .local, + decodedConnectionState: .local + ), + HostSummary( + id: firstRemoteID, + configKey: "alias-a", + name: "Alias A", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + HostSummary( + id: secondRemoteID, + configKey: "alias-b", + name: "Alias B", + kind: .remote, + platform: .linux, + sshDestination: "test@example.invalid" + ), + ], + projects: [], + worktrees: [] + ) + let coordinator = WorktreeMutationCoordinator() + let registrations = LockedValue(0) + let events = coordinator.events.sink { event in + if event.phase == .registered { + registrations.withLock { $0 += 1 } + } + } + defer { events.cancel() } + let store = WorkspaceInventoryStore( + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: localID, + snapshot: snapshot, + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator, + kwtProjectRegistration: { path, _ in + KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: path, + lastTouched: nil + ) + } + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + await waitUntilMainActor { model.isWorkspaceInventoryRefreshComplete } + let host = try #require(model.snapshot.host(id: firstRemoteID)) + + let result = await model.registerProject("/srv/repository", on: host) + + #expect(result == .success("Repository")) + #expect(registrations.load() == 1) + await model.shutdown() + } + + @Test("unsaved draft registration refreshes a removed project", arguments: [true, false]) + func unsavedDraftRegistrationRefreshesRemovedProject(keepsSubscriber: Bool) async throws { + let environment = try setupHostEnvironment() + let draft = SSHHost( + configKey: "new-host", + name: "New Host", + platform: .linux, + sshDestination: "test@example.invalid" + ) + let target = CommandHost.ssh(SSHHostInfo( + user: "test", hostname: "example.invalid", port: nil, platform: .posix + )) + let record = KwtProjectRecord( + repository: "example/repository", + name: "Repository", + path: "/srv/repository", + lastTouched: nil + ) + let inventory = KwtHostInventory(projects: [ + KwtProjectInventory(project: record, worktrees: [], warning: nil), + ]) + let coordinator = WorktreeMutationCoordinator() + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in inventory }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let subscriberID = UUID() + let hostID = UUID() + let registrations = [WorkspaceInventoryStore.HostRegistration( + hostID: hostID, commandHost: target, provisioningHost: nil + )] + defer { store.removeSubscriber(id: subscriberID) } + store.updateSubscriber( + id: subscriberID, registrations: registrations, + wantsKwt: true, wantsTmux: false + ) + await waitUntilMainActor { store.snapshot.kwtByHost[target]?.isFresh == true } + #expect(coordinator.acquire(hostID: hostID, projectIdentity: record.repository)) + coordinator.release( + hostID: hostID, projectIdentity: record.repository, + removesProject: true, projectPath: record.path + ) + await waitUntilMainActor { store.snapshot.kwtByHost[target]?.isFresh == true } + #expect(store.snapshot.kwtByHost[target]?.inventory?.projects.isEmpty == true) + if !keepsSubscriber { + store.removeSubscriber(id: subscriberID) + } + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator, + kwtProjectRegistration: { _, _ in record } + ) + + let result = await model.registerRemoteProject(record.path, on: draft) + + #expect(result == .success(record.name)) + if !keepsSubscriber { + store.updateSubscriber( + id: subscriberID, registrations: registrations, + wantsKwt: true, wantsTmux: false + ) + } + await waitUntilMainActor { + store.snapshot.kwtByHost[target]?.inventory?.projects == inventory.projects + } + #expect(store.snapshot.kwtByHost[target]?.inventory?.projects == inventory.projects) + await model.shutdown() + } + + @Test("external additions and removals converge across scenes") + func externalChangesConvergeAcrossScenes() async throws { + let environment = try setupStandardEnvironment() + let secondDatabase = try WorkspaceDatabase.inMemory() + let kwtLoads = LockedValue(0) + let tmuxLoads = LockedValue(0) + var worktree = environment.snapshot.worktrees[0] + worktree.tmuxSessionName = "external-worktree" + let populatedInventory = WorkspaceTmuxTestSupport.inventory( + project: environment.snapshot.projects[0], + worktrees: [worktree] + ) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = kwtLoads.load() + kwtLoads.withLock { $0 += 1 } + return attempt == 1 + ? populatedInventory + : KwtHostInventory(projects: []) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + let attempt = tmuxLoads.load() + tmuxLoads.withLock { $0 += 1 } + return .success(attempt == 1 ? [ + DiscoveredTmuxSession( + name: "external-session", + windowCount: 1, + createdAt: "1721552400", + managed: false + ), + ] : []) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let emptySnapshot = WorkspaceSnapshot( + hosts: environment.snapshot.hosts, + projects: [], + worktrees: [] + ) + let first = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: emptySnapshot, + workspaceInventoryStore: store + ) + let second = try makeModel( + database: secondDatabase, + localHostID: environment.host.id, + snapshot: emptySnapshot, + workspaceInventoryStore: store + ) + first.startKwtInventory() + first.startTmuxSessionDiscovery() + second.startKwtInventory() + second.startTmuxSessionDiscovery() + await waitUntil { + kwtLoads.load() == 1 && tmuxLoads.load() == 1 + } + + first.refreshKwtInventory() + await waitUntilMainActor { + first.snapshot.worktrees.map(\.path) == [worktree.path] + && second.snapshot.worktrees.map(\.path) == [worktree.path] + && first.snapshot.host(id: environment.host.id)? + .tmuxSessions.map(\.name) == ["external-session"] + && second.snapshot.host(id: environment.host.id)? + .tmuxSessions.map(\.name) == ["external-session"] + } + #expect(kwtLoads.load() == 2) + #expect(tmuxLoads.load() == 2) + + first.refreshKwtInventory() + await waitUntilMainActor { + first.snapshot.worktrees.isEmpty + && second.snapshot.worktrees.isEmpty + && first.snapshot.host(id: environment.host.id)? + .tmuxSessions.isEmpty == true + && second.snapshot.host(id: environment.host.id)? + .tmuxSessions.isEmpty == true + } + #expect(kwtLoads.load() == 3) + #expect(tmuxLoads.load() == 3) + + await first.shutdown() + await second.shutdown() + } + + @Test("two scenes share live loads and a later scene reuses the cache") + func scenesShareLiveAndCachedInventory() async throws { + let environment = try setupStandardEnvironment() + let secondDatabase = try WorkspaceDatabase.inMemory() + let thirdDatabase = try WorkspaceDatabase.inMemory() + let kwtLoads = LockedValue(0) + let tmuxLoads = LockedValue(0) + var worktree = environment.snapshot.worktrees[0] + worktree.tmuxSessionName = "worktree-session" + let inventory = WorkspaceTmuxTestSupport.inventory( + project: environment.snapshot.projects[0], + worktrees: [worktree] + ) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtLoads.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxLoads.withLock { $0 += 1 } + return .success([DiscoveredTmuxSession( + name: "external-session", + windowCount: 2, + createdAt: "1721552400", + managed: false + )]) + }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let emptySnapshot = WorkspaceSnapshot( + hosts: environment.snapshot.hosts, + projects: [], + worktrees: [] + ) + let first = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: emptySnapshot, + workspaceInventoryStore: store + ) + let second = try makeModel( + database: secondDatabase, + localHostID: environment.host.id, + snapshot: emptySnapshot, + workspaceInventoryStore: store + ) + + first.startKwtInventory() + first.startTmuxSessionDiscovery() + second.startKwtInventory() + second.startTmuxSessionDiscovery() + + await waitUntilMainActor { + first.snapshot.worktrees.map(\.path) == [worktree.path] + && second.snapshot.worktrees.map(\.path) == [worktree.path] + && first.snapshot.host(id: environment.host.id)? + .tmuxSessions.map(\.name) + == ["external-session"] + && second.snapshot.host(id: environment.host.id)? + .tmuxSessions.map(\.name) + == ["external-session"] + && first.isWorkspaceInventoryRefreshComplete + && second.isWorkspaceInventoryRefreshComplete + } + #expect(kwtLoads.load() == 1) + #expect(tmuxLoads.load() == 1) + + let third = try makeModel( + database: thirdDatabase, + localHostID: environment.host.id, + snapshot: emptySnapshot, + workspaceInventoryStore: store + ) + third.startKwtInventory() + third.startTmuxSessionDiscovery() + await waitUntilMainActor { + third.snapshot.worktrees.map(\.path) == [worktree.path] + && third.snapshot.host(id: environment.host.id)? + .tmuxSessions.map(\.name) + == ["external-session"] + && third.isWorkspaceInventoryRefreshComplete + } + #expect(kwtLoads.load() == 1) + #expect(tmuxLoads.load() == 1) + + await first.shutdown() + await second.shutdown() + await third.shutdown() + } + + @Test("removed worktrees stay excluded for a later scene") + func removedWorktreeStaysExcludedForLaterScene() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + let coordinator = WorktreeMutationCoordinator() + let sharedLoads = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + sharedLoads.withLock { $0 += 1 } + return fixture.beforeRemoval + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let first = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in fixture.beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in }, + worktreeMutationCoordinator: coordinator, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + } + ) + let request = try await first.prepareWorktreeRemoval( + fixture.removable.id + ) + try await first.removeWorktree(request) + #expect(first.snapshot.worktree(id: fixture.removable.id) == nil) + + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: environment.host.id, + snapshot: WorkspaceSnapshot( + hosts: fixture.snapshot.hosts, + projects: [], + worktrees: [] + ), + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + second.startKwtInventory() + await waitUntilMainActor { + !second.snapshot.projects.isEmpty + } + + #expect(second.snapshot.worktrees.contains { + $0.path == fixture.removable.path + } == false) + #expect(sharedLoads.load() == 0) + await first.shutdown() + await second.shutdown() + } + + @Test("removal preflight keeps its inventory out of the shared cache") + func removalPreflightStaysSceneLocal() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + let coordinator = WorktreeMutationCoordinator() + let newer: KwtHostInventory = { + var inventory = fixture.beforeRemoval + inventory.projects[0].worktrees.append(KwtWorktreeRecord( + path: "/tmp/ghosthub-extra", + branch: "feature/extra", + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: "extra-generation", + repository: environment.project.scopedKey, + sessionName: "kwt-ghosthub-extra" + )) + return inventory + }() + let removalGate = AsyncGate() + defer { removalGate.open() } + let store = WorkspaceInventoryStore( + kwtLoader: { _ in newer }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let first = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in fixture.beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in + await removalGate.wait() + }, + worktreeMutationCoordinator: coordinator, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + } + ) + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: environment.host.id, + snapshot: WorkspaceSnapshot( + hosts: fixture.snapshot.hosts, + projects: [], + worktrees: [] + ), + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + first.startKwtInventory() + second.startKwtInventory() + await waitUntilMainActor { + first.snapshot.worktrees.contains { $0.path == "/tmp/ghosthub-extra" } + && second.snapshot.worktrees.contains { + $0.path == "/tmp/ghosthub-extra" + } + } + + let request = try await first.prepareWorktreeRemoval( + fixture.removable.id + ) + let removal = Task { try await first.removeWorktree(request) } + await removalGate.waitUntilWaiting() + + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects[0] + .worktrees.contains { $0.path == "/tmp/ghosthub-extra" } + == true + ) + #expect(second.snapshot.worktrees.contains { + $0.path == "/tmp/ghosthub-extra" + }) + + removalGate.open() + try await removal.value + #expect(first.snapshot.worktree(id: fixture.removable.id) == nil) + await first.shutdown() + await second.shutdown() + } + + @Test("project removal completion reloads shared inventory once") + func projectRemovalReloadsSharedInventoryOnce() async throws { + let environment = try setupStandardEnvironment() + let project = try #require(environment.snapshot.projects.first) + let host = try #require(environment.snapshot.hosts.first) + let inventory = WorkspaceTmuxTestSupport.inventory( + project: project, + worktrees: environment.snapshot.worktrees + ) + let coordinator = WorktreeMutationCoordinator() + let kwtLoads = LockedValue(0) + let tmuxLoads = LockedValue(0) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + kwtLoads.withLock { $0 += 1 } + return inventory + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in + tmuxLoads.withLock { $0 += 1 } + return .success([]) + }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in inventory }, + worktreeMutationCoordinator: coordinator, + kwtProjectRemoval: { path, _, _, _, _ in + KwtProjectRecord( + repository: project.scopedKey, + name: project.name, + path: path, + lastTouched: nil + ) + } + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + await waitUntilMainActor { model.isWorkspaceInventoryRefreshComplete } + #expect(kwtLoads.load() == 1) + #expect(tmuxLoads.load() == 1) + + let result = await model.unregisterProject( + project, + confirmedHost: host + ) + #expect(result == .success(project.name)) + await waitUntilMainActor { + kwtLoads.load() >= 2 && tmuxLoads.load() >= 2 + && model.isWorkspaceInventoryRefreshComplete + } + try await Task.sleep(for: .milliseconds(20)) + #expect(kwtLoads.load() == 2) + #expect(tmuxLoads.load() == 2) + #expect(model.snapshot.project(id: project.id) == nil) + await model.shutdown() + } + + @Test("re-registering a removed project clears its tombstones") + func reregisteringRemovedProjectClearsTombstones() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + let project = try #require(fixture.snapshot.projects.first) + let host = try #require(fixture.snapshot.hosts.first) + let coordinator = WorktreeMutationCoordinator() + let record = KwtProjectRecord( + repository: project.scopedKey, + name: project.name, + path: project.rootPath, + lastTouched: nil + ) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in fixture.beforeRemoval }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in fixture.beforeRemoval }, + kwtWorktreeRemover: { _, _, _, _, _ in }, + worktreeMutationCoordinator: coordinator, + kwtProjectRegistration: { _, _ in record }, + kwtProjectRemoval: { _, _, _, _, _ in record }, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + } + ) + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: environment.host.id, + snapshot: WorkspaceSnapshot( + hosts: fixture.snapshot.hosts, + projects: [], + worktrees: [] + ), + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + second.startKwtInventory() + await waitUntilMainActor { + model.isWorkspaceInventoryRefreshComplete + && second.snapshot.worktrees.contains { + $0.path == fixture.removable.path + } + } + + let request = try await model.prepareWorktreeRemoval( + fixture.removable.id + ) + try await model.removeWorktree(request) + #expect(model.snapshot.worktree(id: fixture.removable.id) == nil) + await waitUntilMainActor { + !second.snapshot.worktrees.contains { + $0.path == fixture.removable.path + } + } + + let removal = await model.unregisterProject( + project, + confirmedHost: host + ) + #expect(removal == .success(project.name)) + await waitUntilMainActor { + model.snapshot.project(id: project.id) == nil + } + + let registration = await model.registerProject( + project.rootPath, + on: host + ) + #expect(registration == .success(project.name)) + await waitUntilMainActor { + model.snapshot.worktrees.contains { + $0.path == fixture.removable.path + } + } + #expect(model.snapshot.projects.contains { + $0.scopedKey == project.scopedKey + }) + #expect(model.snapshot.worktrees.contains { + $0.path == fixture.removable.path + }) + await waitUntilMainActor { + second.snapshot.worktrees.contains { + $0.path == fixture.removable.path + } + } + #expect(second.snapshot.worktrees.contains { + $0.path == fixture.removable.path + }) + await model.shutdown() + await second.shutdown() + } + + @Test("scene-local loads honor shared removal tombstones") + func sceneLocalLoadsHonorSharedRemovalTombstones() async throws { + let fixture = try removalFixture() + let environment = fixture.environment + var other = WorktreeSummary.fixture( + hostID: environment.host.id, + projectID: environment.project.id, + scopedKey: "/tmp/ghosthub-other", + name: "feature/other", + path: "/tmp/ghosthub-other", + branch: "feature/other", + generation: "fedcba9876543210fedcba9876543210" + ) + other.tmuxSessionName = "kwt-ghosthub-other" + var snapshot = fixture.snapshot + snapshot.worktrees.append(other) + var inventory = fixture.beforeRemoval + inventory.projects[0].worktrees.append(KwtWorktreeRecord( + path: other.path, + branch: other.branch, + commitHash: "abc123", + isMain: false, + createdAt: nil, + generation: other.generation, + repository: environment.project.scopedKey, + sessionName: "kwt-ghosthub-other" + )) + let listing = inventory + let otherPath = other.path + let coordinator = WorktreeMutationCoordinator() + let removalGate = AsyncGate() + defer { removalGate.open() } + let store = WorkspaceInventoryStore( + kwtLoader: { _ in listing }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in listing }, + kwtWorktreeRemover: { path, _, _, _, _ in + if path == otherPath { + await removalGate.wait() + } + }, + worktreeMutationCoordinator: coordinator, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + } + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + await waitUntilMainActor { model.isWorkspaceInventoryRefreshComplete } + + let first = try await model.prepareWorktreeRemoval( + fixture.removable.id + ) + try await model.removeWorktree(first) + await waitUntilMainActor { + model.isWorkspaceInventoryRefreshComplete + && model.snapshot.worktree(id: fixture.removable.id) == nil + } + + let second = try await model.prepareWorktreeRemoval(other.id) + let removal = Task { try await model.removeWorktree(second) } + await removalGate.waitUntilWaiting() + #expect(!model.snapshot.worktrees.contains { + $0.path == fixture.removable.path + }) + removalGate.open() + try await removal.value + #expect(!model.snapshot.worktrees.contains { + $0.path == fixture.removable.path + }) + await model.shutdown() + } + + @Test("provisional shared inventory keeps the refresh incomplete") + func provisionalSharedInventoryKeepsRefreshIncomplete() async throws { + let environment = try setupStandardEnvironment() + let inventory = WorkspaceTmuxTestSupport.inventory( + project: environment.snapshot.projects[0], + worktrees: environment.snapshot.worktrees + ) + let store = WorkspaceInventoryStore( + kwtLoader: { _ in inventory }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: WorktreeMutationCoordinator() + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + workspaceInventoryStore: store + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + await waitUntilMainActor { model.isWorkspaceInventoryRefreshComplete } + + store.publishKwtInventory( + inventory, + on: .local, + mutation: nil, + recordsSuccessfulLoad: false + ) + #expect(!model.isWorkspaceInventoryRefreshComplete) + + store.publishKwtInventory(inventory, on: .local, mutation: nil) + #expect(model.isWorkspaceInventoryRefreshComplete) + await model.shutdown() + } + + @Test("a legacy project's worktree removal survives an identity change") + func legacyWorktreeRemovalSurvivesIdentityChange() async throws { + var fixture = try removalFixture() + let environment = fixture.environment + fixture.snapshot.projects[0].scopedKey = "" + var legacy = fixture.beforeRemoval + legacy.projects[0].project.repository = "" + for index in legacy.projects[0].worktrees.indices { + legacy.projects[0].worktrees[index].repository = "" + } + let canonical = fixture.beforeRemoval + let legacyInventory = legacy + let loads = LockedValue(0) + let coordinator = WorktreeMutationCoordinator() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in legacyInventory }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in + loads.withLock { $0 += 1 } + return loads.load() == 1 ? legacyInventory : canonical + }, + kwtWorktreeRemover: { _, _, _, _, _ in }, + worktreeMutationCoordinator: coordinator, + tmuxSessionIdentityReader: { selection, host in + throw TmuxSessionKillError.sessionNotRunning( + host: host.displayName, + session: selection.name + ) + } + ) + model.startKwtInventory() + model.startTmuxSessionDiscovery() + await waitUntilMainActor { model.isWorkspaceInventoryRefreshComplete } + + let request = try await model.prepareWorktreeRemoval( + fixture.removable.id + ) + try await model.removeWorktree(request) + + #expect(!model.snapshot.worktrees.contains { + $0.path == fixture.removable.path + }) + #expect( + store.snapshot.kwtByHost[.local]?.inventory?.projects.first? + .worktrees.contains { $0.path == fixture.removable.path } + == false + ) + await model.shutdown() + } + + @Test("cached tombstone filtering preserves a KWT refresh failure") + func cachedTombstoneFilteringPreservesRefreshFailure() async throws { + enum RefreshFailure: LocalizedError { + case failed + + var errorDescription: String? { "Inventory refresh failed" } + } + + let fixture = try removalFixture() + let environment = fixture.environment + let coordinator = WorktreeMutationCoordinator() + let postMutationLoad = AsyncGate() + let loadCount = LockedValue(0) + defer { postMutationLoad.open() } + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + let attempt = loadCount.load() + loadCount.withLock { $0 += 1 } + if attempt == 0 { + throw RefreshFailure.failed + } + await postMutationLoad.wait() + return fixture.beforeRemoval + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + store.publishKwtInventory( + fixture.beforeRemoval, + on: .local, + mutation: nil + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: fixture.snapshot, + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + model.startKwtInventory() + model.refreshKwtInventory() + await waitUntilMainActor { + model.workspaceInventoryWarningsByHost[environment.host.id] + == "Inventory refresh failed" + } + + #expect(coordinator.acquire( + hostID: environment.host.id, + projectIdentity: environment.project.scopedKey + )) + coordinator.release( + hostID: environment.host.id, + projectIdentity: environment.project.scopedKey, + removalTombstones: [.init( + path: fixture.removable.path, + generation: fixture.removable.generation ?? "" + )] + ) + await waitUntilMainActor { + model.snapshot.worktree(id: fixture.removable.id) == nil + } + + #expect( + model.workspaceInventoryWarningsByHost[environment.host.id] + == "Inventory refresh failed" + ) + await model.shutdown() + } +} diff --git a/Tests/App/WorkspaceTmuxInventoryTests.swift b/Tests/App/WorkspaceTmuxInventoryTests.swift index 270e2085..4658750e 100644 --- a/Tests/App/WorkspaceTmuxInventoryTests.swift +++ b/Tests/App/WorkspaceTmuxInventoryTests.swift @@ -543,12 +543,14 @@ extension WorkspaceTmuxDiscoveryTests { ) await waitUntilMainActor { - model.workspaceInventoryState == .loaded + model.snapshot.hosts.first { + $0.configKey == remote.configKey + }?.connectionState == .online } - let remoteHostID = try #require( model.snapshot.hosts.first { $0.configKey == remote.configKey }?.id ) + #expect(model.workspaceInventoryWarningsByHost[remoteHostID] == nil) let remoteSummary = try #require( model.snapshot.host(id: remoteHostID) diff --git a/Tests/App/WorkspaceTmuxKillTests.swift b/Tests/App/WorkspaceTmuxKillTests.swift index 3d8d1db8..e23a9f1b 100644 --- a/Tests/App/WorkspaceTmuxKillTests.swift +++ b/Tests/App/WorkspaceTmuxKillTests.swift @@ -208,7 +208,6 @@ extension WorkspaceTmuxDiscoveryTests { .tmuxSessions.first?.windows.count == 3 } - #expect(attempts.count == 2) #expect( model.snapshot.host(id: environment.host.id)? .tmuxSessions.first?.windows.count == 3 @@ -963,6 +962,7 @@ extension WorkspaceTmuxDiscoveryTests { #expect(!model.activeBorrowedTmuxSessionIsConfirmedEnded) model.retryBorrowedTmuxSession(selection) #expect(model.activeBorrowedTmuxLaunchMode == .attach) + await model.shutdown() } @MainActor diff --git a/Tests/App/WorkspaceTmuxProjectRemovalTests.swift b/Tests/App/WorkspaceTmuxProjectRemovalTests.swift index 8bec5882..79ae57c9 100644 --- a/Tests/App/WorkspaceTmuxProjectRemovalTests.swift +++ b/Tests/App/WorkspaceTmuxProjectRemovalTests.swift @@ -1975,6 +1975,272 @@ extension WorkspaceTmuxDiscoveryTests { await model.shutdown() } + @MainActor + @Test("Quarantine resolution hides a legacy-identity record at the removed path") + func quarantineResolutionHidesLegacyRecordAtRemovedPath() async throws { + let environment = try setupStandardEnvironment() + let snapshot = environment.snapshot + let project = try #require(snapshot.projects.first) + let worktree = try #require(snapshot.worktrees.first) + let coordinator = WorktreeMutationCoordinator() + #expect(coordinator.acquire( + hostID: project.hostID, + projectIdentity: project.scopedKey + )) + coordinator.prepareRemoval( + hostID: project.hostID, + projectIdentity: project.scopedKey, + worktrees: [WorktreeMutationCoordinator.RemovalTombstone( + path: worktree.path, + generation: worktree.generation ?? "" + )], + presentationTargets: [] + ) + coordinator.quarantineProjectRemoval( + hostID: project.hostID, + projectIdentity: project.scopedKey, + projectPath: project.rootPath, + host: .local + ) + let legacy = KwtHostInventory(projects: [ + KwtProjectInventory( + project: KwtProjectRecord( + repository: "", + name: project.name, + path: project.rootPath, + lastTouched: nil + ), + worktrees: [], + warning: nil + ), + ]) + let store = WorkspaceInventoryStore( + refreshInterval: .seconds(3_600), + kwtLoader: { _ in legacy }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + workspaceInventoryStore: store, + kwtInventoryLoader: { _ in legacy }, + worktreeMutationCoordinator: coordinator + ) + + model.startKwtInventory() + + await waitUntilMainActor { coordinator.scopes.isEmpty } + await waitUntilMainActor { + !model.snapshot.projects.contains { + $0.rootPath == project.rootPath + } + } + #expect(!model.snapshot.projects.contains { + $0.rootPath == project.rootPath + }) + await model.shutdown() + } + + @MainActor + @Test("Removing a legacy-identity project keeps other legacy projects") + func removingLegacyProjectKeepsOtherLegacyProjects() async throws { + let environment = try setupStandardEnvironment() + var snapshot = environment.snapshot + let removed = ProjectSummary( + id: UUID(), + hostID: environment.host.id, + scopedKey: "", + name: "Removed", + rootPath: "/tmp/legacy-removed" + ) + let kept = ProjectSummary( + id: UUID(), + hostID: environment.host.id, + scopedKey: "", + name: "Kept", + rootPath: "/tmp/legacy-kept" + ) + snapshot.projects.append(contentsOf: [removed, kept]) + let coordinator = WorktreeMutationCoordinator() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + worktreeMutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: environment.host.id, + projectIdentity: "" + )) + + coordinator.release( + hostID: environment.host.id, + projectIdentity: "", + removesProject: true, + allowsRemovalRestoration: false, + projectPath: removed.rootPath + ) + + #expect(model.snapshot.project(id: removed.id) == nil) + #expect(model.snapshot.project(id: kept.id) != nil) + #expect(model.snapshot.project(id: environment.project.id) != nil) + await model.shutdown() + } + + @MainActor + @Test("Removing a canonical project removes its legacy-identity record") + func removingCanonicalProjectRemovesLegacyRecord() async throws { + let environment = try setupStandardEnvironment() + var snapshot = environment.snapshot + let project = try #require(snapshot.projects.first) + let legacyTwin = ProjectSummary( + id: UUID(), + hostID: environment.host.id, + scopedKey: "", + name: project.name, + rootPath: project.rootPath + ) + let other = ProjectSummary( + id: UUID(), + hostID: environment.host.id, + scopedKey: "", + name: "Other", + rootPath: "/tmp/legacy-other" + ) + snapshot.projects.append(contentsOf: [legacyTwin, other]) + let coordinator = WorktreeMutationCoordinator() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + worktreeMutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: project.hostID, + projectIdentity: project.scopedKey + )) + + coordinator.release( + hostID: project.hostID, + projectIdentity: project.scopedKey, + removesProject: true, + allowsRemovalRestoration: false, + projectPath: project.rootPath + ) + + #expect(model.snapshot.project(id: project.id) == nil) + #expect(model.snapshot.project(id: legacyTwin.id) == nil) + #expect(model.snapshot.project(id: other.id) != nil) + await model.shutdown() + } + + @MainActor + @Test("Removing a project keeps the same repository registered elsewhere") + func removingProjectKeepsSameRepositoryElsewhere() async throws { + let environment = try setupStandardEnvironment() + var snapshot = environment.snapshot + let project = try #require(snapshot.projects.first) + let elsewhere = ProjectSummary( + id: UUID(), + hostID: environment.host.id, + scopedKey: project.scopedKey, + name: project.name, + rootPath: "/tmp/ghosthub-elsewhere" + ) + snapshot.projects.append(elsewhere) + let coordinator = WorktreeMutationCoordinator() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + worktreeMutationCoordinator: coordinator + ) + #expect(coordinator.acquire( + hostID: project.hostID, + projectIdentity: project.scopedKey + )) + + coordinator.release( + hostID: project.hostID, + projectIdentity: project.scopedKey, + removesProject: true, + allowsRemovalRestoration: false, + projectPath: project.rootPath + ) + + #expect(model.snapshot.project(id: project.id) == nil) + #expect(model.snapshot.project(id: elsewhere.id) != nil) + await model.shutdown() + } + + @MainActor + @Test("Quarantine resolution ignores the same repository at another path") + func quarantineResolutionIgnoresSameRepositoryElsewhere() async throws { + let environment = try setupStandardEnvironment() + let snapshot = environment.snapshot + let project = try #require(snapshot.projects.first) + let worktree = try #require(snapshot.worktrees.first) + let coordinator = WorktreeMutationCoordinator() + #expect(coordinator.acquire( + hostID: project.hostID, + projectIdentity: project.scopedKey + )) + coordinator.prepareRemoval( + hostID: project.hostID, + projectIdentity: project.scopedKey, + worktrees: [WorktreeMutationCoordinator.RemovalTombstone( + path: worktree.path, + generation: worktree.generation ?? "" + )], + presentationTargets: [] + ) + coordinator.quarantineProjectRemoval( + hostID: project.hostID, + projectIdentity: project.scopedKey, + projectPath: project.rootPath, + host: .local + ) + var elsewhere = project + elsewhere.rootPath = "/tmp/ghosthub-elsewhere" + let inventory = WorkspaceTmuxTestSupport.inventory( + project: elsewhere, + worktrees: [] + ) + let removed = LockedValue(nil) + let events = coordinator.events.sink { event in + if event.phase == .ended { + removed.store(event.removesProject) + } + } + defer { events.cancel() } + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: snapshot, + kwtInventoryLoader: { _ in inventory }, + worktreeMutationCoordinator: coordinator + ) + + model.startKwtInventory() + + await waitUntilMainActor { coordinator.scopes.isEmpty } + // The quarantined project was removed rather than restored onto the + // registration elsewhere. + #expect(removed.load() == true) + await waitUntilMainActor { + model.snapshot.projects.contains { + $0.rootPath == elsewhere.rootPath + } + } + #expect(!model.snapshot.projects.contains { + $0.rootPath == project.rootPath + }) + await model.shutdown() + } + @MainActor @Test("Replacement endpoint cannot classify an old quarantine as removed") func replacementEndpointDoesNotResolveOldQuarantine() async throws { diff --git a/Tests/App/WorkspaceTmuxRecoveryTests.swift b/Tests/App/WorkspaceTmuxRecoveryTests.swift index 6314fc5e..63491f68 100644 --- a/Tests/App/WorkspaceTmuxRecoveryTests.swift +++ b/Tests/App/WorkspaceTmuxRecoveryTests.swift @@ -1,6 +1,7 @@ import GhosthubTransport import Combine import Foundation +import GhosthubPersistence import Synchronization import GhosthubSettings import GhosthubTerminal @@ -103,14 +104,23 @@ extension WorkspaceTmuxDiscoveryTests { let surfaceStore = RecordingNativeSessionSurfaceStore( closeOnRegistrationCode: 255 ) + let coordinator = WorktreeMutationCoordinator() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in KwtHostInventory(projects: []) }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) let model = try makeModel( database: environment.database, localHostID: environment.localHostID, snapshot: environment.snapshot, + workspaceInventoryStore: store, nativeTmuxSurfaceStore: surfaceStore, remoteTmuxPathProvider: { _, _ in successfulTmuxResolution("/usr/bin/tmux") }, + worktreeMutationCoordinator: coordinator, tmuxSessionValidationDiscovery: { _, _ in .success([ DiscoveredTmuxSession( @@ -126,6 +136,18 @@ extension WorkspaceTmuxDiscoveryTests { }, tmuxReconnectIntervals: [.milliseconds(1)] ) + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: environment.localHostID, + snapshot: WorkspaceSnapshot( + hosts: environment.snapshot.hosts, + projects: [], + worktrees: [] + ), + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + second.startTmuxSessionDiscovery() let selection = WorkspaceTmuxSessionSelection( hostID: environment.remoteHost.id, name: "release-work" @@ -139,7 +161,16 @@ extension WorkspaceTmuxDiscoveryTests { #expect(surfaceStore.requestedConfigurations.count >= 2) #expect(model.activeBorrowedTmuxSessionIsConnected) + await waitUntilMainActor { + second.snapshot.host(id: environment.remoteHost.id)? + .tmuxSessions.map(\.name) == ["release-work"] + } + #expect( + second.snapshot.host(id: environment.remoteHost.id)? + .tmuxSessions.map(\.name) == ["release-work"] + ) await model.shutdown() + await second.shutdown() } @MainActor diff --git a/Tests/App/WorkspaceTmuxThemeTests.swift b/Tests/App/WorkspaceTmuxThemeTests.swift index 871f8f10..43dd3d5c 100644 --- a/Tests/App/WorkspaceTmuxThemeTests.swift +++ b/Tests/App/WorkspaceTmuxThemeTests.swift @@ -309,6 +309,7 @@ struct WorkspaceTmuxThemeTests { generalDiscoveryCanFindSession.store(true) scene.model.startTmuxSessionDiscovery() + scene.model.refreshTmuxSessionDiscovery() await waitUntilMainActor { scene.model.pendingCreatedTmuxSessionCount == 0 && appliedIdentities.load() == [sessionIdentity] diff --git a/Tests/App/WorkspaceWorktreeCreationTests.swift b/Tests/App/WorkspaceWorktreeCreationTests.swift index 48213f2a..ab1bc417 100644 --- a/Tests/App/WorkspaceWorktreeCreationTests.swift +++ b/Tests/App/WorkspaceWorktreeCreationTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import GhosthubPersistence import GhosthubSettings import GhosthubTransport import GhosthubUI @@ -258,12 +259,7 @@ struct WorkspaceWorktreeCreationTests { ) secondModel.startKwtInventory() - for _ in 0 ..< 1_000 { - if await inventoryRace.firstCallStarted { - break - } - await Task.yield() - } + await waitUntilMainActor { await inventoryRace.firstCallStarted } #expect(await inventoryRace.firstCallStarted) let mutation = Task { @MainActor in @@ -275,25 +271,17 @@ struct WorkspaceWorktreeCreationTests { ) ) } - for _ in 0 ..< 1_000 { - if await mutationHold.started { - break - } - await Task.yield() - } + await waitUntilMainActor { await mutationHold.started } #expect(await mutationHold.started) await inventoryRace.releaseFirstCall() await mutationHold.release() await mutation.value - for _ in 0 ..< 10_000 { - if await inventoryRace.calls >= 2, - secondModel.snapshot.worktrees.contains(where: { - $0.branch == "feature/refreshed" - }) { - break - } - await Task.yield() + await waitUntilMainActor { + await inventoryRace.calls >= 2 + && secondModel.snapshot.worktrees.contains(where: { + $0.branch == "feature/refreshed" + }) } #expect(await inventoryRace.calls >= 2) #expect(secondModel.snapshot.worktrees.contains { @@ -665,6 +653,14 @@ struct WorkspaceWorktreeCreationTests { isPrimary: true, tmuxSessionName: "kwt-workspace-kwt" )) + snapshot.directoryWorkspaces.append(DirectoryWorkspaceSummary( + id: UUID(), + hostID: environment.host.id, + name: "scratch", + path: "/tmp/scratch", + tmuxSessionName: "kwt-directory-scratch", + sessionLive: false + )) let workspace = PullRequestWorkspace( id: "workspace-32", repository: "github.com/kenn-io/ghosthub", @@ -688,16 +684,30 @@ struct WorkspaceWorktreeCreationTests { isImported: true, workspace: workspace ) + let coordinator = WorktreeMutationCoordinator() + let store = WorkspaceInventoryStore( + kwtLoader: { _ in + throw KwtInventoryError.commandFailed( + host: "this Mac", + status: 1 + ) + }, + kwtProvisioner: { _ in }, + tmuxLoader: { _ in .success([]) }, + mutationCoordinator: coordinator + ) let model = try makeModel( database: environment.database, localHostID: environment.host.id, snapshot: snapshot, + workspaceInventoryStore: store, kwtInventoryLoader: { _ in throw KwtInventoryError.commandFailed( host: "this Mac", status: 1 ) }, + worktreeMutationCoordinator: coordinator, kwtPullRequestImporter: { id, identity, _ in #expect(id == candidate.id) #expect(identity == "github.com/kenn-io/ghosthub") @@ -732,7 +742,32 @@ struct WorkspaceWorktreeCreationTests { #expect(imported.pullRequestState == .open) #expect(model.snapshot.project(id: unrelatedProjectID) != nil) #expect(model.snapshot.worktree(id: unrelatedWorktreeID) != nil) + #expect(model.snapshot.directoryWorkspaces.contains { + $0.path == "/tmp/scratch" + }) + + let second = try makeModel( + database: WorkspaceDatabase.inMemory(), + localHostID: environment.host.id, + snapshot: WorkspaceSnapshot( + hosts: snapshot.hosts, + projects: [], + worktrees: [] + ), + workspaceInventoryStore: store, + worktreeMutationCoordinator: coordinator + ) + second.startKwtInventory() + await waitUntilMainActor { + second.snapshot.worktrees.contains { + $0.path == workspace.path + } + } + #expect(second.snapshot.worktrees.contains { + $0.path == workspace.path + }) await model.shutdown() + await second.shutdown() } private func inventory( diff --git a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift index b5377a7a..2f2bc1e0 100644 --- a/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift +++ b/Tests/App/WorkspaceWorktreeRemovalRecoveryTests.swift @@ -227,8 +227,9 @@ extension WorkspaceWorktreeRemovalTests { model.startKwtInventory() model.startTmuxSessionDiscovery() await waitUntilMainActor { - loads.load() >= 3 && model.isWorkspaceInventoryRefreshComplete + loads.load() == 3 && model.isWorkspaceInventoryRefreshComplete } + #expect(loads.load() == 3) #expect(model.snapshot.worktrees.contains { $0.path == removable.path && $0.generation == stableWorktreeGeneration @@ -917,10 +918,10 @@ extension WorkspaceWorktreeRemovalTests { model.startKwtInventory() model.startTmuxSessionDiscovery() await waitUntilMainActor { - loads.load() >= 3 && model.isWorkspaceInventoryRefreshComplete + loads.load() == 2 && model.isWorkspaceInventoryRefreshComplete } - #expect(loads.load() == 3) + #expect(loads.load() == 2) #expect(!model.snapshot.worktrees.contains { $0.hostID == removable.hostID && $0.path == removable.path }) diff --git a/docs/architecture.md b/docs/architecture.md index c966b996..69f5973b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1088,12 +1088,26 @@ Zellij's unformatted session list is independently authoritative for active Zellij sessions. Exited entries are excluded. Missing Zellij is silent, while malformed output or another failure produces only a host-scoped warning. Zellij fleet sweeps accumulate their host results and publish the completed -runtime inventory once. Tmux and Herdr publish host-scoped runtime results as -hosts complete. Session-only publication uses the runtime overlay, which cannot -reconcile kwt projects or worktrees and cannot normalize their paths. The full -overlay remains reserved for authoritative kwt changes. Application activation -starts neither inventory discovery nor process sampling; explicit refresh and -lifecycle reconciliation own those costs. +runtime inventory once. Herdr publishes host-scoped runtime results as hosts +complete. The macOS app owns KWT and tmux fleet inventory in one process-wide +cache keyed by resolved command host. Workspace scenes subscribe with their +stable host identities and apply cached results through their scene-local +reconciliation guards. The first subscriber refreshes both lanes, one cadence +refreshes them every 30 seconds while the app is active, and reactivation +refreshes them immediately. Failed refreshes retain the last successful rows +while revoking freshness and publishing host-scoped warnings. The cache also +owns the refresh that follows a worktree mutation: an authoritative +post-mutation KWT publication satisfies it, and tmux is reloaded once for the +mutated host. A mutation captures the cache's mutation epoch for its host when +it acquires its scope; a publication whose epoch predates a later mutation on +that host is rejected, and the fence-end reload reconciles instead. Inventory +loaded for removal preflight or failed-removal classification stays local to +the mutating scene and still excludes the cache's active removal tombstones. Scene-local tmux probes capture the cache's refresh epoch before +they run and publish to it only when no newer shared refresh has started since. +Herdr and Zellij inventories remain scene-owned. Session-only publication uses the runtime +overlay, which cannot reconcile KWT projects or worktrees and cannot normalize +their paths. The full overlay remains reserved for authoritative KWT changes. +Application activation still does not start process sampling. Ghosthub local persistence stores app-owned state: