diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift new file mode 100644 index 000000000000..3b7355976034 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionTrackedWork.swift @@ -0,0 +1,82 @@ +/// Returns one registry-owned effect for helpers that report work and result tasks. +/// Existing helper deadlines and result fields remain intact. +@MainActor +public enum ActionTrackedWork { + typealias Answer = Result<[String: ActionResultValue], ActionWorkFailure> + + public static func effect( + registry: ActionRegistry, + cancellationFailure: ActionWorkFailure, + waiting: ActionWait = .network, + authorize: (@MainActor () async -> ActionWorkFailure?)? = nil, + cancelAuthorization: @escaping @MainActor () -> Void = {}, + start: @escaping @MainActor () -> ActionWorkFailure? + ) -> ActionEffect { + let scope = ActionRunScope.current + let capturedByCaller = registry.isCapturingWork + let answers = AsyncStream.makeStream(bufferingPolicy: .bufferingOldest(1)) + if capturedByCaller { + // task-owner: result-only relay; the registry effect below always finishes its stream. + registry.track(result: Task { + for await answer in answers.stream { return answer } + return .failure(cancellationFailure) + }) + } + // action-wait-allow: the caller names its service wait; this adapter preserves helper work and result tracking. + return .waiting(waiting) { + let answer = await withTaskCancellationHandler { + if let failure = await authorize?() { return Answer.failure(failure) } + guard !Task.isCancelled else { return Answer.failure(cancellationFailure) } + return await run(registry: registry, scope: scope, capturedByCaller: capturedByCaller, + cancellationFailure: cancellationFailure, start: start) + } onCancel: { + // task-owner: one-shot cancellation invalidates the reserved sign-in generation. + Task { @MainActor in cancelAuthorization() } + } + answers.continuation.yield(answer) + answers.continuation.finish() + if case .failure(let failure) = answer { return failure } + return nil + } + } + + private static func run(registry: ActionRegistry, scope: ActionRunScope?, capturedByCaller: Bool, + cancellationFailure: ActionWorkFailure, + start: @MainActor () -> ActionWorkFailure?) async -> Answer { + var captured: (work: [ActionWork], results: [ActionResultWork]) = ([], []) + var startFailure: ActionWorkFailure? + let refusal = ActionRunScope.carrying(scope) { + // A control-socket/CLI caller captures refusals and receives them + // in its action result. Interactive runs must leave refusal + // reporting enabled so CloudHandlers.runTracked can show its + // normal failure sheet. + if capturedByCaller { + return registry.capturingRefusal { + captured = registry.capturingRun { startFailure = start() } + } + } + captured = registry.capturingRun { startFailure = start() } + return nil + } + let work = captured.work, results = captured.results + return await withTaskCancellationHandler { + var failure = startFailure ?? refusal.map { ActionWorkFailure(refusal: .unavailable, reason: $0) } + for task in work { + if let next = await task.value, failure == nil { failure = next } + } + guard !Task.isCancelled else { return .failure(cancellationFailure) } + if let failure { return .failure(failure) } + var fields: [String: ActionResultValue] = [:] + for task in results { + switch await task.value { + case .success(let next): fields.merge(next) { _, new in new } + case .failure(let failure): return .failure(failure) + } + } + return .success(fields) + } onCancel: { + for task in work { task.cancel() } + for task in results { task.cancel() } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift index e5e4503d5018..263db529aeef 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Actions.swift @@ -16,17 +16,15 @@ extension AgentPaneModel { /// Search chats) and the History search page (the + menu's Search pages). They open the app's own /// palette, as Cmd-K does, so they need no gesture credit. static let paletteActions: Set = ["agentPane.searchChats", "history.search"] - /// What runs when a connect is refused: signed out, cmux Cloud… starts sign-in. - static let connectFallbacks: [String: String] = ["newCloudMachine": "palette.auth.signIn"] /// `action.run` from the page. Any agent tab may open the New Tab /// page (a blank chat's New), the command palette's chats page ("Show all", decision K1), and the /// location row's SSH and cmux Cloud connect flows (Lawrence 2026-10-06: "I cannot click on cmux /// Cloud SSH"). Integrate a harness is the page's own Harnesses page (cx-uj5g8). /// A connect flow opens app UI, so it uses the user's fresh gesture in the pane (the grant - /// credit, as Choose Folder…); page script cannot make one. Its sign-in fallback runs only - /// inside that same gesture. An action the app did not run (turned off, or not for this tab), with - /// no fallback that ran, is an `unavailable` failure, so the page can say so; one that ran and + /// credit, as Choose Folder…); page script cannot make one. The native creation handler owns + /// sign-in and its continuation. An action the app did not run (turned off, or not for this tab) + /// is an `unavailable` failure, so the page can say so; one that ran and /// then stopped reports itself in the app (its refusal or toast). func runAction(_ id: String) -> [String: Any] { guard id == "newTab.page" || Self.paletteActions.contains(id) || Self.connectActions.contains(id), @@ -35,7 +33,6 @@ extension AgentPaneModel { } if Self.connectActions.contains(id), !transport.gestures.consume() { return Self.transportFailure(.gestureRequired) } if onRunAction(id, Self.connectArguments[id] ?? [:]) { return AgentPaneReply.success() } - if let fallback = Self.connectFallbacks[id], onRunAction(fallback, [:]) { return AgentPaneReply.success() } return AgentPaneReply.failure(code: "unavailable", message: "The app did not run \(id).") } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift index 0ab00fbdd1b6..b3c91db834e4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService+AppServer.swift @@ -182,3 +182,10 @@ private nonisolated final class CloudRefuseRedirects: NSObject, URLSessionTaskDe completionHandler(nil) } } + +extension CloudService { + nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { + guard let loadedAt, loadedFor == account else { return false } + return loadedAt.duration(to: now) < age + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift index 021b57fa4b01..744805e355c1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Cloud/CloudService.swift @@ -107,6 +107,7 @@ final class CloudService { @ObservationIgnored let startSessionAsks = CloudStartSessionAsks() /// `cmux cloud start-session` runs, joined by a repeat of the same ask (``CloudStartSessionRuns``). @ObservationIgnored let startSessionRuns = CloudStartSessionRuns() + @ObservationIgnored let signedOutCreation = CloudSignedOutCreationContinuation() /// When the loaded list landed (``hasCurrentList(within:)``). @ObservationIgnored private var listLoadedAt: ContinuousClock.Instant? /// Moved chats' file requests for this Mac, asked in their chats. @@ -304,6 +305,7 @@ final class CloudService { guard let self else { return } var last = account for await account in ObservationStream({ self.account }) where account != last { + signedOutCreation.scopeDidChange(from: last, to: account) last = account await codexBroker.scopeDidChange() startSessionRuns.forgetAll() @@ -326,6 +328,7 @@ final class CloudService { // homes for a later confirmed run after policy is restored. codexBroker.setAvailable(!disabled) if disabled { + signedOutCreation.cancel() for session in machines.cloud { session.daemon.policyBlock.set(true) session.disconnect() @@ -348,6 +351,7 @@ final class CloudService { } func stop() { + signedOutCreation.cancel() for observer in observers { observer.cancel() } observers.removeAll() if let linkEvents { machines.local.store.sideEvents.unsubscribe(linkEvents) } @@ -455,11 +459,6 @@ final class CloudService { Self.listIsCurrent(loadedAt: hasLoadedMachines ? listLoadedAt : nil, loadedFor: loadedAccount, account: listAccount, now: .now, within: age) } - nonisolated static func listIsCurrent(loadedAt: ContinuousClock.Instant?, loadedFor: [String?]?, account: [String?], now: ContinuousClock.Instant, within age: Duration) -> Bool { - guard let loadedAt, loadedFor == account else { return false } - return loadedAt.duration(to: now) < age - } - private func reconcile(_ list: [CloudMachine]) { // A list that arrives after Cloud was turned off connects nothing. guard !policyDisabled else { return } @@ -680,6 +679,7 @@ final class CloudService { var willSignOut: [@MainActor () -> Void] = [] func signOut() async { + signedOutCreation.cancel() // Start-session asks and runs end now, not after the link and install cleanup below. startSessionRuns.forgetAll() startSessionAsks.endAll() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift index a6368ae49d2d..a16c0e50a64e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers+Account.swift @@ -14,20 +14,9 @@ import CmuxNextWakeups // and diagnostics. extension CloudHandlers { static func bindCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { - bind("newCloudMachine", registry, reason: reason) { invocation in - let request = try newMachineRequest(invocation) - let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil - if request.isDefault, origin(of: invocation) == .user, window != nil { - beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession, chat: CloudChatPick.chatTab(invocation, context.services)) - return - } - let flow = creationFlow(context, startedByPerson: invocation.origin == .user, askedBy: invocation.callerAgentSession) - guard request.isDefault else { return createRequested(request, flow: flow, window: window, context) } - // The new machine's progress shows in the window at once; its - // first workspace opens there when its daemon is ready. - reportCreatedMachine(flow.start(in: window, existing: nil), context) - } - bind("newCloudWorkspace", registry, reason: reason) { invocation in + let cloud = context.services.cloud + bindMachineCreation(into: registry, context: context, reason: reason) + bind("newCloudWorkspace", registry, reason: { reason() ?? (cloud.isSignedIn ? nil : CloudStrings.signInFirst) }) { invocation in let target = try workspaceMachine(invocation, context) let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil guard let session = target, session.daemon.connection != nil else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift index b7cb2df63b2b..9df765650139 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/CloudHandlers.swift @@ -24,7 +24,7 @@ enum CloudHandlers { bindDomainActions(into: registry, context: context, reason: signedInReason) bindListActions(into: registry, context: context) CloudStartSession.bind(into: registry, context: context, reason: signedInReason) - bindCreation(into: registry, context: context, reason: signedInReason) + bindCreation(into: registry, context: context, reason: { cloud.unavailableReason }) bindAccount(into: registry, context: context, reason: reason) bindMove(into: registry, context: context) // Any catalog row added later without a handler stays covered. @@ -40,6 +40,60 @@ enum CloudHandlers { assert(bound, "\(id) is not in the action catalog") } + static func bindMachineCreation(into registry: ActionRegistry, context: AppActionContext, reason: @escaping @MainActor () -> String?) { + let cloud = context.services.cloud + let signedOutCreation = cloud.signedOutCreation + let bound = registry.bindEffect("newCloudMachine", requires: nil, unavailable: reason) { invocation in + let request = try CloudHandlers.newMachineRequest(invocation) + let actionOrigin = CloudHandlers.origin(of: invocation) + let window = invocation.allowsViewChange ? context.services.windows.active?.state : nil + // The chat the person asked from, read now: sign-in may finish after focus moved. + let chat = CloudChatPick.chatTab(invocation, context.services) + let start: @MainActor () -> ActionWorkFailure? = { + guard cloud.isSignedIn else { return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) } + if let reason = cloud.unavailableReason { return ActionWorkFailure(refusal: .unavailable, reason: reason) } + if let window, context.services.windows.controller(for: window.id) == nil { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined) + } + if request.isDefault, actionOrigin == .user, window != nil { + CloudHandlers.beginWorkspaceSetup(context, window: window, askedBy: invocation.callerAgentSession, chat: chat) + return nil + } + let flow = CloudHandlers.creationFlow(context, startedByPerson: actionOrigin == .user, + askedBy: invocation.callerAgentSession) + guard request.isDefault else { + CloudHandlers.createRequested(request, flow: flow, window: window, context) + return nil + } + CloudHandlers.reportCreatedMachine(flow.start(in: window, existing: nil), context) + return nil + } + guard !cloud.isSignedIn else { + signedOutCreation.cancel() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined), start: start) + } + guard actionOrigin == .user else { + return .refused(CloudStrings.signInFirst) + } + let expected = signedOutCreation.reserve() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined), authorize: { + let authorized = await signedOutCreation.perform(expected: expected, + signIn: { await cloud.auth.signIn() }, resume: {}) + guard authorized, cloud.isSignedIn else { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.signInFirst) + } + if let reason = cloud.unavailableReason { + return ActionWorkFailure(refusal: .unavailable, reason: reason) + } + if let window, context.services.windows.controller(for: window.id) == nil { + return ActionWorkFailure(refusal: .unavailable, reason: CloudStrings.createDeclined) + } + return nil + }, cancelAuthorization: { signedOutCreation.cancel(expected: expected) }, start: start) + } + assert(bound, "newCloudMachine is not in the action catalog") + } + // MARK: Resolution /// The Cloud machine an invocation acts on. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift index 786f55931987..e9cb4773ed91 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Remote/AddDeviceServices+App.swift @@ -33,8 +33,7 @@ extension AddDeviceServices { enterCode: { [weak services] in _ = services?.registry.perform("server.addServer") }, // Signed out, New Cloud Machine is refused: sign-in starts instead (the agent pane's same fallback). newCloudMachine: { [weak services] in - guard let registry = services?.registry, !registry.perform("newCloudMachine") else { return } - _ = registry.perform("palette.auth.signIn") + _ = services?.registry.perform("newCloudMachine") }, copy: { CloudPresenter.copy($0) }, // Labs labs.machineJoin: read at each opening, so the switch takes effect on the next one. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift new file mode 100644 index 000000000000..2b62fe7b187d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloud/Auth/CloudSignedOutCreationContinuation.swift @@ -0,0 +1,64 @@ +/// Owns one person-initiated creation while hosted Cloud sign-in is pending. +/// Replacement, sign-out, and policy changes invalidate the previous generation. +@MainActor +public final class CloudSignedOutCreationContinuation { + private var generation = 0 + private var pendingGeneration: Int? + + public init() {} + + /// Reserves one replay generation synchronously, before an action hands its + /// asynchronous sign-in work to ActionRegistry. + public func reserve() -> Int { + cancel() + pendingGeneration = generation + return generation + } + + /// Completes a reserved sign-in. The registry owns cancellation of this + /// operation; a stale generation can never replay the captured creation. + @discardableResult + public func perform( + expected: Int, + signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void + ) async -> Bool { + guard pendingGeneration == expected, generation == expected, !Task.isCancelled else { + cancel(expected: expected) + return false + } + let succeeded = await signIn() + guard pendingGeneration == expected, generation == expected else { return false } + pendingGeneration = nil + guard succeeded, !Task.isCancelled else { + generation += 1 + return false + } + // Consume before calling out; replay may itself start another intent. + generation += 1 + resume() + return true + } + + /// Hosted auth publishes its user before loading the initial active team. + /// That nil-to-team hydration belongs to the sign-in we are awaiting. + /// A later user replacement, sign-out, or populated-team change does not. + public func scopeDidChange(from previous: [String?], to current: [String?]) { + guard isPending else { return } + if previous == [nil, nil], current[0] != nil { return } + if previous[0] != nil, previous[0] == current[0], previous[1] == nil, current[1] != nil { return } + cancel() + } + + public func cancel() { + generation += 1 + pendingGeneration = nil + } + + public func cancel(expected: Int) { + guard pendingGeneration == expected, generation == expected else { return } + cancel() + } + + public var isPending: Bool { pendingGeneration != nil } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift index a64606d7e2c9..5726a564feaf 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionEffectTests.swift @@ -106,6 +106,41 @@ import Testing #expect(registry.waitingRuns["test.rename"]?[.daemonEcho] == 2) } + /// Tracked interactive work must leave refusal reporting enabled so the + /// App can present its normal failure notice. A captured CLI run still + /// receives the refusal through its action result. + @Test func trackedWorkKeepsInteractiveFailuresVisibleAndCapturesCliRefusals() async { + let registry = ActionRegistry(catalog: Self.catalog) + var interactiveRefusalCapture = true + var notices: [String] = [] + var onNotice: (() -> Void)? + registry.remoteFailureObserver = { _, notice in + notices.append(notice) + onNotice?() + } + registry.bindEffect("test.rename") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled")) { + interactiveRefusalCapture = registry.isCapturingRefusal + return ActionWorkFailure("create failed") + } + } + await withCheckedContinuation { (done: CheckedContinuation) in + onNotice = { done.resume() } + #expect(registry.perform("test.rename")) + } + #expect(!interactiveRefusalCapture) + #expect(notices.count == 1) + #expect(notices[0].contains("create failed")) + + let cli = registry.capturingWork { + #expect(registry.perform("test.rename", invocation: ActionInvocation(origin: .cli))) + } + var failures: [String?] = [] + for task in cli { failures.append((await task.value)?.message) } + #expect(failures.contains("create failed")) + #expect(notices.count == 1) + } + @Test func aRefusedEffectRefuses() { let registry = ActionRegistry(catalog: Self.catalog) registry.bindEffect("test.rename", effect: { _ in .refused("no name") }) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift index 30f4ad5ef5a8..a77b57457fad 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneConnectActionTests.swift @@ -34,6 +34,16 @@ struct AgentPaneConnectActionTests { #expect(arguments == [[:]]) } + @Test func refusedCloudCreationDoesNotLoseIntentToStandaloneSignIn() async { + let model = AgentPaneModel(host: NoHost()) + var ran: [String] = [] + model.onRunAction = { id, _ in ran.append(id); return false } + model.transport.gestures.record() + let reply = await model.respond(to: .runAction("newCloudMachine")) + #expect(reply["ok"] as? Bool != true) + #expect(ran == ["newCloudMachine"]) + } + /// Connect Phone… opens the Mobile Pairing page, on the person's gesture only. @Test func connectPhoneOpensMobilePairingOnAGesture() async { let model = AgentPaneModel(host: NoHost()) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift index af30ddc038fb..70d6a090ac17 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CloudWorkspaceSetupLifecycleTests.swift @@ -1,4 +1,6 @@ @testable import CmuxNextApp +import CmuxNextActions +import CmuxNextCloud import CmuxNextCloudUI import CmuxNextCompat import Testing @@ -64,3 +66,294 @@ struct CloudWorkspaceSetupLifecycleTests { #expect(CloudHandlers.workspaceSetupReadiness(creation, in: creations) == .ended) } } + +@MainActor +private final class CloudSignInTestGate { + private var result: CheckedContinuation? + private var requested: CheckedContinuation? + + func waitUntilRequested() async { + if result != nil { return } + await withCheckedContinuation { requested = $0 } + } + + func wait() async -> Bool { + await withCheckedContinuation { + result = $0 + requested?.resume() + requested = nil + } + } + + func resolve(_ value: Bool) { + result?.resume(returning: value) + result = nil + } +} + +@MainActor @Suite(.timeLimit(.minutes(1))) +struct CloudSignedOutCreationContinuationTests { + @Test func creationEffectOwnsPostAuthWorkAndPreservesResult() async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var starts = 0 + registry.bindEffect("test.create") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled"), authorize: { nil }) { + starts += 1 + registry.track(result: Task { + _ = await gate.wait() + return .success(["machine_id": .string("created"), "pending": .bool(true)]) + }) + return nil + } + } + let captured = registry.capturingRun { registry.perform("test.create") } + #expect(starts == 0, "creation cannot start inside the synchronous handler") + #expect(captured.results.count == 1) + await gate.waitUntilRequested() + #expect(starts == 1) + gate.resolve(true) + for work in captured.work { #expect(await work.value == nil) } + let result = try #require(captured.results.first) + guard case .success(let fields) = await result.value else { + Issue.record("creation result was lost") + return + } + #expect(fields["machine_id"] == .string("created")) + #expect(fields["pending"] == .bool(true)) + } + + @Test(arguments: ["window closed", "cloud unavailable"]) + func postAuthDestinationFailureReachesRegistry(reason: String) async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let handoff = CloudSignedOutCreationContinuation() + var creations = 0 + registry.bindEffect("test.create") { _ in + let expected = handoff.reserve() + return ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled"), authorize: { + let signedIn = await handoff.perform(expected: expected, signIn: { true }, resume: {}) + return signedIn ? nil : ActionWorkFailure("sign-in failed") + }) { + guard reason.isEmpty else { return ActionWorkFailure(refusal: .unavailable, reason: reason) } + creations += 1 + return nil + } + } + let captured = registry.capturingRun { registry.perform("test.create") } + for work in captured.work { #expect(await work.value?.message == reason) } + #expect(creations == 0) + #expect(!handoff.isPending) + } + + @Test func cancellingCreationEffectCancelsItsTrackedWork() async throws { + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var child: ActionWork? + registry.bindEffect("test.create") { _ in + ActionTrackedWork.effect(registry: registry, cancellationFailure: ActionWorkFailure("cancelled")) { + let task: ActionWork = Task { + _ = await gate.wait() + return Task.isCancelled ? ActionWorkFailure("cancelled") : nil + } + child = task + registry.track(task) + return nil + } + } + let work = registry.capturingWork { registry.perform("test.create") } + await gate.waitUntilRequested() + for task in work { task.cancel() } + gate.resolve(true) + for task in work { #expect(await task.value != nil) } + #expect(try #require(child).isCancelled) + } + + @Test func registryOwnsSignInAndReportsFailure() async throws { + let handoff = CloudSignedOutCreationContinuation() + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + var signIns = 0, resumes = 0 + registry.bindEffect("test.create") { _ in + handoff.effect(signIn: { signIns += 1; return false }, resume: { resumes += 1 }) + } + let work = registry.capturingWork { registry.perform("test.create") } + #expect(signIns == 0) + #expect(work.count == 1) + #expect(registry.waitingRuns["test.create"]?[.network] == 1) + let pending = try #require(work.first) + #expect(await pending.value != nil) + #expect(signIns == 1) + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func cancelledRegistryWorkReportsFailureAndNeverReplays() async throws { + let handoff = CloudSignedOutCreationContinuation() + let registry = ActionRegistry(catalog: [ActionDescriptor(id: "test.create", title: "Create", category: .other)]) + let gate = CloudSignInTestGate() + var resumes = 0 + registry.bindEffect("test.create") { _ in + handoff.effect(signIn: { await gate.wait() }, resume: { resumes += 1 }) + } + let pending = try #require(registry.capturingWork { registry.perform("test.create") }.first) + await gate.waitUntilRequested() + pending.cancel() + gate.resolve(true) + #expect(await pending.value != nil) + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func replacedReservationNeverStartsAuthentication() async { + let handoff = CloudSignedOutCreationContinuation() + let old = handoff.reserve() + _ = handoff.reserve() + var signIns = 0 + let resumed = await handoff.perform(expected: old, signIn: { signIns += 1; return true }, resume: {}) + #expect(!resumed) + #expect(signIns == 0) + #expect(handoff.isPending) + handoff.cancel() + } + + @Test func resumesTheCapturedIntentOnce() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var intent = "original" + var resumed: [String] = [] + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { [intent] in resumed.append(intent) }) + await gate.waitUntilRequested() + intent = "later" + gate.resolve(true) + await pending.value + #expect(resumed == ["original"]) + #expect(intent == "later") + #expect(!handoff.isPending) + } + + @Test func replacementIgnoresLateResultFromTheOriginalIntent() async { + let handoff = CloudSignedOutCreationContinuation() + let first = CloudSignInTestGate() + let second = CloudSignInTestGate() + var resumed: [String] = [] + let old = handoff.replace(signIn: { await first.wait() }, resume: { resumed.append("old") }) + await first.waitUntilRequested() + let latest = handoff.replace(signIn: { await second.wait() }, resume: { resumed.append("latest") }) + await second.waitUntilRequested() + first.resolve(true) + await old.value + #expect(resumed.isEmpty) + #expect(handoff.isPending) + second.resolve(true) + await latest.value + #expect(resumed == ["latest"]) + #expect(!handoff.isPending) + } + + @Test func signOutInvalidationIgnoresLateSignInResult() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.cancel() + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func dismissedSignInNeverResumesAndClearsPendingIntent() async { + let handoff = CloudSignedOutCreationContinuation() + var resumes = 0 + await handoff.replace(signIn: { false }, resume: { resumes += 1 }).value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func signInUserThenInitialTeamHydrationStillResumes() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, nil], to: ["user", nil]) + handoff.scopeDidChange(from: ["user", nil], to: ["user", "team"]) + #expect(handoff.isPending) + gate.resolve(true) + await pending.value + #expect(resumes == 1) + } + + @Test func nilUserTeamHydrationCannotResumeAnewSignIn() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, nil], to: [nil, "team"]) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func staleTeamRestorationCannotResumeAnewSignIn() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: [nil, "old-team"], to: ["new-user", "new-team"]) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test(arguments: [["other", "team"], ["user", "other"], ["user", nil], [nil, nil]]) + func accountOrTeamReplacementInvalidatesPendingSignIn(current: [String?]) async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + handoff.scopeDidChange(from: ["user", "team"], to: current) + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } + + @Test func cancelledWaiterCannotResumeEvenIfAuthSucceeded() async { + let handoff = CloudSignedOutCreationContinuation() + let gate = CloudSignInTestGate() + var resumes = 0 + let pending = handoff.replace(signIn: { await gate.wait() }, resume: { resumes += 1 }) + await gate.waitUntilRequested() + pending.cancel() + gate.resolve(true) + await pending.value + #expect(resumes == 0) + #expect(!handoff.isPending) + } +} + +/// Tests own their auth tasks; production work is started by ActionRegistry. +@MainActor +private extension CloudSignedOutCreationContinuation { + func replace(signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void) -> Task { + let expected = reserve() + return Task { _ = await perform(expected: expected, signIn: signIn, resume: resume) } + } + + func effect(signIn: @escaping @MainActor () async -> Bool, + resume: @escaping @MainActor () -> Void) -> ActionEffect { + let expected = reserve() + // action-wait-allow: exercise the registry-owned authentication wait. + return .waiting(.network) { + let resumed = await self.perform(expected: expected, signIn: signIn, resume: resume) + return resumed ? nil : ActionWorkFailure("sign-in failed") + } + } +}