From 7960511d323186912816490351f1e1fa2e2b313a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:17:11 +0900 Subject: [PATCH 1/5] ci(docs): publish DocC on main pushes Generate warning-free static documentation for every public library product and deploy it through GitHub Pages. Keep pull requests as build-only validation and make the external consumer fixture independent of checkout directory names. --- .github/workflows/documentation.yml | 74 +++++++++++++++++++ Docs/DocCSite/index.html | 40 ++++++++++ .../Package.swift | 2 +- Package.resolved | 20 ++++- Package.swift | 1 + README.md | 2 + .../CodexThreadOperations.swift | 2 + .../CodexAppServerTestRuntime.swift | 8 +- scripts/generate-docc-site.sh | 57 ++++++++++++++ 9 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/documentation.yml create mode 100644 Docs/DocCSite/index.html create mode 100755 scripts/generate-docc-site.sh diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..aa1b6f3 --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,74 @@ +name: Documentation + +on: + push: + branches: + - main + pull_request: + paths: + - .github/workflows/documentation.yml + - Docs/DocCSite/** + - Package.swift + - Package.resolved + - README.md + - Sources/** + - scripts/generate-docc-site.sh + workflow_dispatch: + +concurrency: + group: documentation-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + DEVELOPER_DIR: /Applications/Xcode_26.6.0.app/Contents/Developer + EXPECTED_XCODE_VERSION_LINE: Xcode 26.6 + EXPECTED_XCODE_BUILD_LINE: Build version 17F113 + +jobs: + build: + name: Build DocC + runs-on: macos-26 + timeout-minutes: 15 + permissions: + contents: read + pages: read + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Verify GitHub Actions pinning + run: ruby scripts/verify-github-actions-pinning.rb + + - name: Verify pinned Xcode + run: scripts/verify-ci-xcode.sh + + - name: Configure GitHub Pages + if: github.event_name == 'push' + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Build documentation + run: scripts/generate-docc-site.sh "$RUNNER_TEMP/codexreviewkit-docc" CodexReviewKit + + - name: Upload GitHub Pages artifact + if: github.event_name == 'push' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/codexreviewkit-docc + + deploy: + name: Deploy GitHub Pages + if: github.event_name == 'push' + needs: build + runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/Docs/DocCSite/index.html b/Docs/DocCSite/index.html new file mode 100644 index 0000000..8ee04c2 --- /dev/null +++ b/Docs/DocCSite/index.html @@ -0,0 +1,40 @@ + + + + + + + CodexReviewKit Documentation + + + +
+

CodexReviewKit Documentation

+

API documentation for the public Swift package products.

+ +

Source and installation

+
+ + diff --git a/Fixtures/CodexReviewKitProductConsumer/Package.swift b/Fixtures/CodexReviewKitProductConsumer/Package.swift index c4b69af..2616f8f 100644 --- a/Fixtures/CodexReviewKitProductConsumer/Package.swift +++ b/Fixtures/CodexReviewKitProductConsumer/Package.swift @@ -14,7 +14,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../.."), + .package(name: "CodexReviewKit", path: "../.."), ], targets: [ .executableTarget( diff --git a/Package.resolved b/Package.resolved index 3dc5273..dc2689f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e64b4c51c05f762b778f36a7fcff26834a31424abb07eee8f8173ea2cbcf725b", + "originHash" : "9317724d9c293b71e4fa617d888b222f6c73c88c90968f2541559ccdc34e3f36", "pins" : [ { "identity" : "eventsource", @@ -46,6 +46,24 @@ "version" : "1.4.1" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-plugin", + "state" : { + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 91535b9..46cb33e 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-nio.git", from: "2.97.1"), .package(url: "https://github.com/lynnswap/ObservationBridge.git", .upToNextMinor(from: "0.12.0")), .package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.0"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.5.0"), ], targets: [ .target( diff --git a/README.md b/README.md index d039570..6307e58 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,8 @@ used by that Claude Code session. ## More Detail +- [API documentation](https://lynnswap.github.io/CodexReviewKit/): generated + DocC for every public library product. - [Architecture](Docs/architecture.md): package boundaries, runtime flow, and test responsibilities. - [CodexKit integration design](Docs/codexkit-integration.md): the canonical diff --git a/Sources/CodexAppServerKit/CodexThreadOperations.swift b/Sources/CodexAppServerKit/CodexThreadOperations.swift index 5dd3127..16d027b 100644 --- a/Sources/CodexAppServerKit/CodexThreadOperations.swift +++ b/Sources/CodexAppServerKit/CodexThreadOperations.swift @@ -48,6 +48,8 @@ extension CodexThread { /// - Parameters: /// - prompt: The structured prompt to send. /// - options: Per-turn generation options. + /// - timeout: The maximum duration to wait for a terminal outcome, or + /// `nil` to wait without a deadline. /// - Returns: The completed response collected from app-server events. public func respond( to prompt: CodexPrompt, diff --git a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift index 8874db4..f2637ef 100644 --- a/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift +++ b/Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift @@ -637,7 +637,7 @@ public final class CodexAppServerTestDeadlineClock: Sendable { /// /// This type does not launch `codex` or any external process. Tests enqueue /// responses and emit notifications through ``transport`` while exercising the -/// same public ``CodexAppServer`` API that production code uses. +/// same public `CodexAppServer` API that production code uses. public struct CodexAppServerTestRuntime: Sendable { /// The app-server domain container under test. public let server: CodexAppServer @@ -678,7 +678,7 @@ public struct CodexAppServerTestRuntime: Sendable { /// Creates a test runtime without launching a real app-server process. /// /// The runtime automatically enqueues the `initialize` response required by - /// ``CodexAppServer`` startup. + /// `CodexAppServer` startup. /// /// - Parameters: /// - transport: The in-memory transport to use. @@ -721,7 +721,7 @@ public struct CodexAppServerTestRuntime: Sendable { /// Creates a test runtime whose app-server thread APIs are backed by a mutable store. /// - /// The returned runtime still exercises the public ``CodexAppServer`` API, + /// The returned runtime still exercises the public `CodexAppServer` API, /// while callers can mutate `threadStore` after startup: /// /// ```swift @@ -751,7 +751,7 @@ public struct CodexAppServerTestRuntime: Sendable { /// Creates a test runtime whose thread APIs are backed by validated opaque fixtures. /// - /// The returned runtime still exercises the public ``CodexAppServer`` API. + /// The returned runtime still exercises the public `CodexAppServer` API. /// Higher-level code can build its normal data container from ``server``: /// /// ```swift diff --git a/scripts/generate-docc-site.sh b/scripts/generate-docc-site.sh new file mode 100755 index 0000000..7218918 --- /dev/null +++ b/scripts/generate-docc-site.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$#" -ne 2 ]]; then + printf 'usage: %s \n' "$0" >&2 + exit 64 +fi + +output_directory="$1" +hosting_base_path="${2%/}" + +if [[ "$output_directory" != /* ]]; then + printf 'output directory must be absolute: %s\n' "$output_directory" >&2 + exit 64 +fi + +if [[ -z "$hosting_base_path" ]]; then + printf 'hosting base path must not be empty\n' >&2 + exit 64 +fi + +if [[ -e "$output_directory" ]]; then + printf 'output directory already exists: %s\n' "$output_directory" >&2 + exit 1 +fi + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_directory/.." && pwd)" +cd "$repository_root" + +targets=( + CodexAppServerKit + CodexAppServerKitTesting + CodexDataKit + CodexReviewKit + CodexReviewHost + ReviewUI + ReviewUIPreviewSupport + TextTransitions +) + +mkdir -p "$output_directory" +cp Docs/DocCSite/index.html "$output_directory/index.html" + +for target in "${targets[@]}"; do + target_output="$output_directory/$target" + swift package \ + --allow-writing-to-directory "$target_output" \ + generate-documentation \ + --target "$target" \ + --output-path "$target_output" \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "$hosting_base_path/$target" \ + --warnings-as-errors +done From 518cdce9b48fda1c4d71e6602053b29fbaf50d5a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:14:36 +0900 Subject: [PATCH 2/5] test(app-server): synchronize reasoning assertions --- .../CodexAppServerKitTests.swift | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index e3481b1..5d55aa4 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -6778,9 +6778,59 @@ struct CodexAppServerKitTests { router: router, connectionLease: harness.lease ) - let eventsTask = Task { try await collect(thread.events) } - let logsTask = Task { try await collect(thread.logEntries) } + let reasoningEventsObserved = CodexAppServerTestGate() + let reasoningLogsObserved = CodexAppServerTestGate() + let eventsTask = Task { + var events: [CodexThreadEvent] = [] + var observedSummaryPart = false + var observedContentDelta = false + for try await event in thread.events { + events.append(event) + switch event { + case .reasoningSummaryPartAdded(let part, let turnID): + observedSummaryPart = observedSummaryPart + || (part.id == "reasoning-1:summary:0" && turnID == "turn-1") + case .reasoningDelta(let delta, let turnID): + observedContentDelta = observedContentDelta + || (delta.id == "reasoning-1:content:1" + && delta.delta == "Raw trace" + && turnID == "turn-1") + default: + break + } + if observedSummaryPart && observedContentDelta { + await reasoningEventsObserved.open() + } + } + return events + } + let logsTask = Task { + var logs: [CodexThreadLogEntry] = [] + var observedSummaryStart = false + var observedSummaryDelta = false + var observedContentDelta = false + for try await log in thread.logEntries { + logs.append(log) + observedSummaryStart = observedSummaryStart + || (log.id == "reasoning-1:summary:0" && log.phase == .started) + observedSummaryDelta = observedSummaryDelta + || (log.reasoningDelta?.id == "reasoning-1:summary:0" + && log.reasoningDelta?.delta == "Checking") + observedContentDelta = observedContentDelta + || (log.reasoningDelta?.id == "reasoning-1:content:1" + && log.reasoningDelta?.delta == "Raw trace") + if observedSummaryStart && observedSummaryDelta && observedContentDelta { + await reasoningLogsObserved.open() + } + } + return logs + } let transcriptsTask = Task { try await collect(thread.transcriptUpdates) } + defer { + eventsTask.cancel() + logsTask.cancel() + transcriptsTask.cancel() + } #expect(await eventually { router.threadSubscriberCountForTesting(for: "thread-1") == 3 }) @@ -6825,6 +6875,10 @@ struct CodexAppServerKitTests { delta: "Raw trace" ) ) + try await withTimeout { + try await reasoningEventsObserved.wait() + try await reasoningLogsObserved.wait() + } try await transport.emitServerNotification( method: "item/completed", params: ThreadItemParams( From 95cf9ab989da982186fc0be7b673153044a97e35 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:23:31 +0900 Subject: [PATCH 3/5] ci(docs): publish DocC only from main --- .github/workflows/documentation.yml | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index aa1b6f3..02f023f 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -4,20 +4,10 @@ on: push: branches: - main - pull_request: - paths: - - .github/workflows/documentation.yml - - Docs/DocCSite/** - - Package.swift - - Package.resolved - - README.md - - Sources/** - - scripts/generate-docc-site.sh - workflow_dispatch: concurrency: group: documentation-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false env: DEVELOPER_DIR: /Applications/Xcode_26.6.0.app/Contents/Developer @@ -45,21 +35,18 @@ jobs: run: scripts/verify-ci-xcode.sh - name: Configure GitHub Pages - if: github.event_name == 'push' uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Build documentation run: scripts/generate-docc-site.sh "$RUNNER_TEMP/codexreviewkit-docc" CodexReviewKit - name: Upload GitHub Pages artifact - if: github.event_name == 'push' uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: ${{ runner.temp }}/codexreviewkit-docc deploy: name: Deploy GitHub Pages - if: github.event_name == 'push' needs: build runs-on: ubuntu-24.04 permissions: From 66f59a05089afa0b4a3266d24dbe87811018aed4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:18:22 +0900 Subject: [PATCH 4/5] fix(host): publish auth completion after ownership release --- .../AccountRuntimeTransitionCoordinator.swift | 27 +- .../LiveCodexReviewStoreBackend.swift | 295 ++++++++++++------ .../CodexReviewHostTests.swift | 94 +++++- 3 files changed, 301 insertions(+), 115 deletions(-) diff --git a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift index 8fac15a..6be7391 100644 --- a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift +++ b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift @@ -127,6 +127,8 @@ final class AccountRuntimeTransitionCoordinator { fileprivate let id: UUID } + typealias PrimaryReconciliationFinishAction = @MainActor @Sendable () -> Void + private enum ActiveTransition { enum RuntimeAuthPhase { case reading @@ -171,7 +173,7 @@ final class AccountRuntimeTransitionCoordinator { let completion: PrimaryReconciliationCompletion let operation: @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? } private var pendingPrimaryReconciliation: PendingPrimaryReconciliation? @@ -300,7 +302,7 @@ final class AccountRuntimeTransitionCoordinator { _ admission: LoginAdmission, operation: @escaping @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? ) -> PrimaryLoginReconciliationHandoff { guard case .primaryLogin(let id) = activeTransition, id == admission.id else { @@ -591,7 +593,7 @@ final class AccountRuntimeTransitionCoordinator { func admitPrimaryReconciliation( _ operation: @escaping @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? ) -> PrimaryReconciliationAdmission { if case .account = activeTransition { guard pendingPrimaryReconciliation == nil else { @@ -613,7 +615,7 @@ final class AccountRuntimeTransitionCoordinator { func performStoppedPrimaryReconciliation( _ operation: @escaping @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? ) async { let pending = makePendingPrimaryReconciliation(operation) if case .account = activeTransition { @@ -802,7 +804,10 @@ final class AccountRuntimeTransitionCoordinator { } } - private func finishTransition(id: UUID) { + private func finishTransition( + id: UUID, + primaryReconciliationFinishAction: PrimaryReconciliationFinishAction? = nil + ) { guard activeTransition?.id == id else { preconditionFailure("Only the active account runtime transition can finish.") } @@ -820,6 +825,7 @@ final class AccountRuntimeTransitionCoordinator { nil } activeTransition = nil + primaryReconciliationFinishAction?() primaryReconciliationCompletion?.resolve() let waiters = transitionCompletionWaiters transitionCompletionWaiters.removeAll(keepingCapacity: false) @@ -832,7 +838,7 @@ final class AccountRuntimeTransitionCoordinator { private func startPrimaryReconciliation( _ operation: @escaping @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? ) { startPrimaryReconciliation(makePendingPrimaryReconciliation(operation)) } @@ -840,7 +846,7 @@ final class AccountRuntimeTransitionCoordinator { private func makePendingPrimaryReconciliation( _ operation: @escaping @MainActor @Sendable ( PrimaryReconciliationReservation - ) async -> Void + ) async -> PrimaryReconciliationFinishAction? ) -> PendingPrimaryReconciliation { let id = UUID() return .init( @@ -860,8 +866,11 @@ final class AccountRuntimeTransitionCoordinator { completion: pending.completion ) Task { @MainActor [weak self] in - await pending.operation(pending.reservation) - self?.finishTransition(id: id) + let finishAction = await pending.operation(pending.reservation) + self?.finishTransition( + id: id, + primaryReconciliationFinishAction: finishAction + ) } } } diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index dd83b93..5bff012 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -41,6 +41,7 @@ package typealias CodexReviewMCPHTTPServerBindChecker = @MainActor @Sendable ( ) async throws -> Void package typealias CodexReviewAuthenticationMutationDidBegin = @Sendable () async -> Void +package typealias CodexReviewAuthenticationOwnershipWillRelease = @MainActor @Sendable () async -> Void package typealias CodexReviewAuthenticationCancellationDidRequest = @Sendable () async -> Void package typealias CodexReviewAuthenticationProductCommitDidApply = @Sendable () async -> Void package typealias CodexReviewAuthenticationOperationDidBind = @Sendable () async -> Void @@ -93,6 +94,7 @@ public extension CodexReviewStore { appServer: CodexAppServer, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil, authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil, @@ -113,6 +115,7 @@ public extension CodexReviewStore { networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationOwnershipWillRelease: authenticationOwnershipWillRelease, authenticationCancellationDidRequest: authenticationCancellationDidRequest, authenticationProductCommitDidApply: authenticationProductCommitDidApply, authenticationOperationDidBind: authenticationOperationDidBind, @@ -141,6 +144,7 @@ public extension CodexReviewStore { networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil, authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil, @@ -162,6 +166,7 @@ public extension CodexReviewStore { mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, appServerLifecycleHandler: appServerLifecycleHandler, authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationOwnershipWillRelease: authenticationOwnershipWillRelease, authenticationCancellationDidRequest: authenticationCancellationDidRequest, authenticationProductCommitDidApply: authenticationProductCommitDidApply, authenticationOperationDidBind: authenticationOperationDidBind, @@ -220,6 +225,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } return false } + + var publishesAuthenticationTerminalPhase: Bool { + guard case .published(let owner) = self else { + return false + } + if case .primary = owner { + return false + } + return true + } + } + + private struct LoginSessionResolution { + let terminal: LoginSessionTerminal + let terminalPhase: CodexReviewAuthModel.Phase? } private enum RuntimeAuthReconciliationCause { @@ -308,6 +328,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private let mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker private let appServerRuntimeFactory: AppServerRuntimeFactory private let appServerCloser: CodexReviewAppServerCloser + private let authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? private let authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? private let finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? private let reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? @@ -359,6 +380,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil, authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil, @@ -397,6 +419,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker self.appServerLifecycleHandler = appServerLifecycleHandler self.appServerCloser = appServerCloser + self.authenticationOwnershipWillRelease = authenticationOwnershipWillRelease self.authenticationOperationDidBind = authenticationOperationDidBind self.finalRuntimeRetirementDidClaim = finalRuntimeRetirementDidClaim self.reconciliationDebtDidClear = reconciliationDebtDidClear @@ -705,7 +728,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { logger.info("Starting review runtime; forceRestartIfNeeded=\(forceRestartIfNeeded, privacy: .public)") if let registryLoadFailure { if shouldPublishRuntimeState(mode: mode) { - store.auth.updatePhase(.failed(registryLoadFailure)) + if mode.publishesAuthenticationTerminalPhase { + store.auth.updatePhase(.failed(registryLoadFailure)) + } store.transitionToFailed(registryLoadFailure.localizedDescription) } return false @@ -873,7 +898,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { if shouldPublishRuntimeState(mode: mode), let reconciledAccountSnapshot = authResolution.persisted { applyAccountRegistrySnapshot(reconciledAccountSnapshot, to: store.auth) - store.auth.updatePhase(.signedOut) + if mode.publishesAuthenticationTerminalPhase { + store.auth.updatePhase(.signedOut) + } } store.transitionToRunning(serverURL: serverURL) await session.mcpHTTPServer?.activate() @@ -1225,9 +1252,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await accountRuntimeTransitionCoordinator.performStoppedPrimaryReconciliation { [weak self, weak store] reservation in guard let self, let store else { - return + return nil } - await self.performPrimaryAuthenticationReconciliation( + return await self.performPrimaryAuthenticationReconciliation( handoff, reservation: reservation, auth: store.auth, @@ -2283,51 +2310,58 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { observation: LoginRootObservation, auth: CodexReviewAuthModel ) async -> LoginSessionTerminal { - let terminal: LoginSessionTerminal + let resolution: LoginSessionResolution switch observation { case .chatGPTOutcome(let outcome): - terminal = await finishLoginOutcome( + resolution = await finishLoginOutcome( outcome, session: session, reason: reason, auth: auth ) case .apiKeySucceeded: - terminal = await finishSuccessfulLogin(session: session, auth: auth) + resolution = await finishSuccessfulLogin(session: session, auth: auth) case .apiKeyOutcomeUnknown: guard session.provider == .apiKey else { preconditionFailure("Only an API-key login can report an unknown immediate authentication outcome.") } if case .signIn = session.purpose { - terminal = .primaryRuntimeReconciliation( - session.takePrimaryAuthenticationReconciliationHandoff( - cause: .apiKeyOutcomeUnknown( - previousActiveAccountKey: session.previousActiveAccountKey + resolution = .init( + terminal: .primaryRuntimeReconciliation( + session.takePrimaryAuthenticationReconciliationHandoff( + cause: .apiKeyOutcomeUnknown( + previousActiveAccountKey: session.previousActiveAccountKey + ) ) - ) + ), + terminalPhase: nil ) } else { let failure = CodexReviewAuthenticationFailure.login( message: "The isolated API-key authentication outcome could not be confirmed." ) - auth.updatePhase(.failed(failure)) - terminal = .failed(failure) + resolution = .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) } case .cancelled: - terminal = finishCancelledLoginOutcome(reason: reason, auth: auth) + resolution = finishCancelledLoginOutcome(reason: reason) case .failure(let failure): - auth.updatePhase(.failed(failure)) - terminal = .failed(failure) + resolution = .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) case .waiterCancelled(let message): - terminal = finishCancelledLoginWaiter( + resolution = finishCancelledLoginWaiter( session: session, reason: reason, - message: message, - auth: auth + message: message ) } await closeLoginRuntimeIfNeeded(session) + let terminal = resolution.terminal if case .primaryRuntimeReconciliation(let handoff) = terminal { let requiresRuntimeStopHandoff: Bool = switch reason { case .runtimeFailure, .storeStop: @@ -2348,9 +2382,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) { [weak self, weak auth] reservation in guard let self, let auth else { - return + return nil } - await self.performPrimaryAuthenticationReconciliation( + return await self.performPrimaryAuthenticationReconciliation( handoff, reservation: reservation, auth: auth, @@ -2370,9 +2404,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } } else { + await authenticationOwnershipWillRelease?() await releaseLoginMutationIfNeeded(session) clearLoginSessionIfCurrent(session) finishPrimaryLoginAdmissionIfCurrent(session) + publishAuthenticationTerminalPhase(resolution.terminalPhase, to: auth) await reconcilePendingRuntimeAuthInvalidation(auth: auth) if case .succeeded = terminal, case .signIn = session.purpose { @@ -2466,7 +2502,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { session: LoginSession, reason terminationReason: LoginTerminationReason, auth: CodexReviewAuthModel - ) async -> LoginSessionTerminal { + ) async -> LoginSessionResolution { precondition(session.provider == .chatGPT) switch outcome { case .succeeded: @@ -2475,40 +2511,44 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let failure = CodexReviewAuthenticationFailure.login( message: message ?? "Authentication failed." ) - auth.updatePhase(.failed(failure)) - return .failed(failure) - case .cancelled: - return finishCancelledLoginOutcome( - reason: terminationReason, - auth: auth + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) ) + case .cancelled: + return finishCancelledLoginOutcome(reason: terminationReason) case .authenticationCommittedNeedsConnectionReconciliation(let reconciliationReason): if case .cancelOutcomeUnknown = reconciliationReason { guard case .signIn = session.purpose else { - return finishCancelledLoginOutcome( - reason: terminationReason, - auth: auth - ) + return finishCancelledLoginOutcome(reason: terminationReason) } - return .primaryRuntimeReconciliation( - session.takePrimaryAuthenticationReconciliationHandoff( - cause: .chatGPTCancelOutcomeUnknown( - previousActiveAccountKey: session.previousActiveAccountKey + return .init( + terminal: .primaryRuntimeReconciliation( + session.takePrimaryAuthenticationReconciliationHandoff( + cause: .chatGPTCancelOutcomeUnknown( + previousActiveAccountKey: session.previousActiveAccountKey + ) ) - ) + ), + terminalPhase: nil ) } guard case .signIn = session.purpose else { let failure = CodexReviewAuthenticationFailure.protocolViolation( message: "An isolated add-account login cannot hand off primary authentication reconciliation." ) - auth.updatePhase(.failed(failure)) - return .failed(failure) - } - return .primaryRuntimeReconciliation( - session.takePrimaryAuthenticationReconciliationHandoff( - cause: .chatGPTCommitted(reconciliationReason) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) ) + } + return .init( + terminal: .primaryRuntimeReconciliation( + session.takePrimaryAuthenticationReconciliationHandoff( + cause: .chatGPTCommitted(reconciliationReason) + ) + ), + terminalPhase: nil ) } } @@ -2516,13 +2556,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func finishSuccessfulLogin( session: LoginSession, auth: CodexReviewAuthModel - ) async -> LoginSessionTerminal { + ) async -> LoginSessionResolution { guard let loginRuntime = await session.runtime() else { let failure = CodexReviewAuthenticationFailure.protocolViolation( message: "Authentication completed without a bound login runtime." ) - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) } var stagingURLRequiringRemoval: URL? var deferredPrimaryExpectation = session.provider.successfulLoginExpectation @@ -2641,10 +2683,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { isolatedProductCommitAuthorization: mutationLease ) } - if claimLoginResultPublication( + let publishesResult = claimLoginResultPublication( for: session, usesPrimaryRuntime: loginRuntime.usesPrimaryRuntime - ) { + ) + if publishesResult { if let primaryObservationPublication { primaryObservationPublication.session.updateAccountObservation( primaryObservationPublication.observation, @@ -2652,41 +2695,50 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } applyAccountRegistrySnapshot(reconciliation.persisted, to: auth) - auth.updatePhase(.signedOut) } if let stagingURLRequiringRemoval { await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval) } - return .succeeded + return .init( + terminal: .succeeded, + terminalPhase: publishesResult ? .signedOut : nil + ) } catch { if let stagingURLRequiringRemoval { await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval) } if error is IsolatedLoginProductCommitCancelled { - auth.updatePhase(.signedOut) - return .cancelled + return .init( + terminal: .cancelled, + terminalPhase: .signedOut + ) } if let productCommitFailure = error as? IsolatedLoginProductCommitFailure { - auth.updatePhase(.failed(productCommitFailure.failure)) - return .failed(productCommitFailure.failure) + return .init( + terminal: .failed(productCommitFailure.failure), + terminalPhase: .failed(productCommitFailure.failure) + ) } if loginRuntime.usesPrimaryRuntime { return await finishPrimaryLoginWithDeferredRegistryReconciliation( session: session, expectedAccount: deferredPrimaryExpectation, - underlyingError: error, - auth: auth + underlyingError: error ) } let failure = (error as? CodexReviewAuthenticationFailure) ?? CodexReviewAuthenticationFailure.login(message: error.localizedDescription) switch await session.claimPreCommitFailure() { case .cancel: - auth.updatePhase(.signedOut) - return .cancelled + return .init( + terminal: .cancelled, + terminalPhase: .signedOut + ) case .fail: - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) } } } @@ -2725,9 +2777,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func finishPrimaryLoginWithDeferredRegistryReconciliation( session: LoginSession, expectedAccount: ExpectedRuntimeAccount, - underlyingError: any Error, - auth: CodexReviewAuthModel - ) async -> LoginSessionTerminal { + underlyingError: any Error + ) async -> LoginSessionResolution { let message = "Authentication succeeded, but account registry reconciliation remains pending: " + underlyingError.localizedDescription do { @@ -2740,12 +2791,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { "Committed primary authentication must durably record reconciliation debt: \(error.localizedDescription)" ) } - if commitPrimaryLoginReconciliationFailure(for: session) { - auth.updatePhase(.failed(.accountCommit(message: message))) + let publishesFailure = commitPrimaryLoginReconciliationFailure(for: session) + if publishesFailure { attachedStore?.transitionToFailed(message) } logger.error("\(message, privacy: .public)") - return .committedNeedsRuntimeReconciliation(message: message) + return .init( + terminal: .committedNeedsRuntimeReconciliation(message: message), + terminalPhase: publishesFailure ? .failed(.accountCommit(message: message)) : nil + ) } private func commitPrimaryLoginReconciliationFailure( @@ -2761,55 +2815,65 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func finishCancelledLoginOutcome( - reason: LoginTerminationReason, - auth: CodexReviewAuthModel - ) -> LoginSessionTerminal { + reason: LoginTerminationReason + ) -> LoginSessionResolution { switch reason { case .urlOpenFailure(let failure), .runtimeFailure(let failure): - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) case .storeStop: - auth.updatePhase(.signedOut) - return .stopped + return .init( + terminal: .stopped, + terminalPhase: .signedOut + ) case .rootOutcome, .explicitCancellation: - auth.updatePhase(.signedOut) - return .cancelled + return .init( + terminal: .cancelled, + terminalPhase: .signedOut + ) } } private func finishCancelledLoginWaiter( session _: LoginSession, reason: LoginTerminationReason, - message: String?, - auth: CodexReviewAuthModel - ) -> LoginSessionTerminal { + message: String? + ) -> LoginSessionResolution { finishLoginWaiterFailure( reason: reason, - message: message ?? "Authentication cancellation failed.", - auth: auth + message: message ?? "Authentication cancellation failed." ) } private func finishLoginWaiterFailure( reason: LoginTerminationReason, - message: String, - auth: CodexReviewAuthModel - ) -> LoginSessionTerminal { + message: String + ) -> LoginSessionResolution { switch reason { case .rootOutcome: let failure = CodexReviewAuthenticationFailure.login(message: message) - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) case .explicitCancellation: let failure = CodexReviewAuthenticationFailure.runtime(message: message) - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) case .urlOpenFailure(let failure), .runtimeFailure(let failure): - auth.updatePhase(.failed(failure)) - return .failed(failure) + return .init( + terminal: .failed(failure), + terminalPhase: .failed(failure) + ) case .storeStop: - auth.updatePhase(.signedOut) - return .stopped + return .init( + terminal: .stopped, + terminalPhase: .signedOut + ) } } @@ -2818,8 +2882,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { reservation: AccountRuntimeTransitionCoordinator.PrimaryReconciliationReservation, auth: CodexReviewAuthModel, oldRuntimeAlreadyStopped: Bool - ) async { + ) async -> AccountRuntimeTransitionCoordinator.PrimaryReconciliationFinishAction { let finalResult: PrimaryAuthenticationReconciliationResult + var terminalPhase: CodexReviewAuthModel.Phase? + var publishesRuntimeState = false let expectedAccount: ExpectedRuntimeAccount = switch handoff.cause { case .chatGPTCommitted: .anyChatGPT @@ -2843,11 +2909,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { if oldRuntimeAlreadyStopped == false { await stop(store: store, purpose: .loginReconciliationPreservingRuns) } + publishesRuntimeState = accountRuntimeTransitionCoordinator + .primaryPublicationClaim(reservation) == .published guard await startRuntime( store: store, forceRestartIfNeeded: true, expectedAccount: expectedAccount, - mode: accountRuntimeTransitionCoordinator.primaryPublicationClaim(reservation) == .published + mode: publishesRuntimeState ? .published(owner: .primary(reservation)) : .quiescentReconciliation, registryAuthorization: handoff.mutationLease, @@ -2893,6 +2961,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { message: "Authentication reconciliation produced an invalid account observation." ) } + if publishesRuntimeState { + terminalPhase = .signedOut + } } catch { let message = "Authentication was committed, but runtime reconciliation remains pending: \(error.localizedDescription)" do { @@ -2907,23 +2978,39 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } if accountRuntimeTransitionCoordinator.commitPrimaryReconciliationFailure(reservation) { attachedStore?.transitionToFailed(message) - auth.updatePhase(.failed(.accountCommit(message: message))) + terminalPhase = .failed(.accountCommit(message: message)) } logger.error( "Primary authentication reconciliation deferred after \(String(describing: handoff.cause), privacy: .public): \(message, privacy: .public)" ) finalResult = .committedNeedsRuntimeReconciliation(message: message) } - if accountRuntimeTransitionCoordinator.isFinalShutdownRequested { - auth.updatePhase(.signedOut) - } + await authenticationOwnershipWillRelease?() await accountRegistry.finishMutation(handoff.mutationLease) - let didResolve = handoff.finalResult.resolve(finalResult) - precondition( - didResolve, - "A primary authentication reconciliation resolver can complete only once." - ) - clearActivePrimaryAuthenticationReconciliation(handoff) + return { @MainActor [self, weak auth] in + clearActivePrimaryAuthenticationReconciliation(handoff) + if let auth { + publishAuthenticationTerminalPhase(terminalPhase, to: auth) + } + let didResolve = handoff.finalResult.resolve(finalResult) + precondition( + didResolve, + "A primary authentication reconciliation resolver can complete only once." + ) + } + } + + private func publishAuthenticationTerminalPhase( + _ terminalPhase: CodexReviewAuthModel.Phase?, + to auth: CodexReviewAuthModel + ) { + let publishedTerminalPhase: CodexReviewAuthModel.Phase? = + accountRuntimeTransitionCoordinator.isFinalShutdownRequested + ? .signedOut + : terminalPhase + if let publishedTerminalPhase { + auth.updatePhase(publishedTerminalPhase) + } } private func installActivePrimaryAuthenticationReconciliation( diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 29c01de..830c8b0 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -24,6 +24,7 @@ private extension CodexReviewStore { networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil, authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil, @@ -45,6 +46,7 @@ private extension CodexReviewStore { networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationOwnershipWillRelease: authenticationOwnershipWillRelease, authenticationCancellationDidRequest: authenticationCancellationDidRequest, authenticationProductCommitDidApply: authenticationProductCommitDidApply, authenticationOperationDidBind: authenticationOperationDidBind, @@ -83,6 +85,7 @@ private extension CodexReviewStore { networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationOwnershipWillRelease: CodexReviewAuthenticationOwnershipWillRelease? = nil, authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, authenticationOperationDidBind: CodexReviewAuthenticationOperationDidBind? = nil, @@ -105,6 +108,7 @@ private extension CodexReviewStore { networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationOwnershipWillRelease: authenticationOwnershipWillRelease, authenticationCancellationDidRequest: authenticationCancellationDidRequest, authenticationProductCommitDidApply: authenticationProductCommitDidApply, authenticationOperationDidBind: authenticationOperationDidBind, @@ -148,6 +152,7 @@ struct CodexReviewHostTests { let coordinator = AccountRuntimeTransitionCoordinator() var admittedLogin: AccountRuntimeTransitionCoordinator.LoginAdmission? var didAdmitLogin = false + var didRunFinishAction = false coordinator.installDidBecomeIdle { guard didAdmitLogin == false else { return } didAdmitLogin = true @@ -155,13 +160,19 @@ struct CodexReviewHostTests { } let reconciliationCompleted = CompletionFlag() let reconciliation = Task { @MainActor in - await coordinator.performStoppedPrimaryReconciliation { _ in } + await coordinator.performStoppedPrimaryReconciliation { _ in + return { + #expect(didAdmitLogin == false) + didRunFinishAction = true + } + } await reconciliationCompleted.complete() } try #require(await waitUntil(timeout: .seconds(2)) { await reconciliationCompleted.isCompleted() }) + #expect(didRunFinishAction) #expect(coordinator.hasActiveLoginTransition) coordinator.finishLoginAdmission(try #require(admittedLogin)) @@ -1426,6 +1437,67 @@ struct CodexReviewHostTests { try await assertUnknownAPIKeyLoginOutcome(committed: false) } + @Test func liveStoreFinalShutdownSupersedesPrimaryReconciliationFailureAtOwnershipRelease() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let initialTransport = FakeCodexAppServerTransport() + try await initialTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await initialTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await initialTransport.enqueueModels(.init(models: [])) + try await initialTransport.enqueueChatGPTLogin( + loginID: "final-shutdown-unknown-api-key-response", + authenticationURL: testAuthenticationURL + ) + let authenticationOwnershipReleaseGate = CodexAppServerTestGate() + let finalShutdownRequested = OneShotSignal() + var runtimeFactoryCallCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationOwnershipWillRelease: { + await authenticationOwnershipReleaseGate.waitIgnoringCancellation() + }, + finalShutdownDidRequest: { + await finalShutdownRequested.signal() + }, + transportFactory: { runtimeHomeURL in + #expect(runtimeHomeURL == codexHomeURL) + runtimeFactoryCallCount += 1 + guard runtimeFactoryCallCount == 1 else { + throw CodexReviewAPI.Error.io("replacement validation runtime unavailable") + } + return initialTransport + } + ) + await store.start(forceRestartIfNeeded: true) + let sentinel = "test-secret-final-shutdown-api-key-outcome" + try writeAPIKeyAuth( + Data("{\"OPENAI_API_KEY\":\"\(sentinel)\"}".utf8), + to: codexHomeURL + ) + + let login = Task { @MainActor in + try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel))) + } + await authenticationOwnershipReleaseGate.waitUntilBlocked() + #expect(store.auth.isAuthenticating) + + let stop = Task { @MainActor in + await store.stop() + } + await finalShutdownRequested.wait() + await authenticationOwnershipReleaseGate.open() + try await login.value + await stop.value + + #expect(store.auth.isAuthenticating == false) + #expect(store.auth.errorMessage == nil) + #expect(store.serverState == .stopped) + #expect(runtimeFactoryCallCount == 2) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + } + @Test func liveStoreDiscardsIsolatedRuntimeWhenAPIKeyOutcomeIsUnknown() async throws { let homeURL = try temporaryHome() let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) @@ -3854,10 +3926,14 @@ struct CodexReviewHostTests { var nonPrimaryTransports = [authTransport, refreshTransport] var nonPrimaryRuntimeIndex = 0 var refreshCodexHomeURL: URL? + let authenticationOwnershipReleaseGate = CodexAppServerTestGate() let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], externalURLOpener: externalURLOpener.open, + authenticationOwnershipWillRelease: { + await authenticationOwnershipReleaseGate.waitIgnoringCancellation() + }, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -3894,6 +3970,9 @@ struct CodexReviewHostTests { authMode: .chatGPT, planType: .plus )) + await authenticationOwnershipReleaseGate.waitUntilBlocked() + #expect(store.auth.isAuthenticating) + await authenticationOwnershipReleaseGate.open() #expect(await waitUntil(timeout: .seconds(1)) { store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" } && store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25 @@ -6617,10 +6696,14 @@ private func assertUnknownAPIKeyLoginOutcome(committed: Bool) async throws { try await reconciliationTransport.enqueueModels(.init(models: [])) } var transports = [initialTransport, reconciliationTransport] + let authenticationOwnershipReleaseGate = CodexAppServerTestGate() let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], externalURLOpener: externalURLOpener.open, + authenticationOwnershipWillRelease: { + await authenticationOwnershipReleaseGate.waitIgnoringCancellation() + }, transportFactory: { runtimeHomeURL in #expect(runtimeHomeURL == codexHomeURL) return transports.removeFirst() @@ -6633,7 +6716,14 @@ private func assertUnknownAPIKeyLoginOutcome(committed: Bool) async throws { to: codexHomeURL ) - try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel))) + let login = Task { @MainActor in + try await store.signIn(using: .apiKey(try CodexReviewAPIKey(validating: sentinel))) + } + await authenticationOwnershipReleaseGate.waitUntilBlocked() + #expect(store.auth.isAuthenticating) + await authenticationOwnershipReleaseGate.open() + try await login.value + #expect(store.auth.isAuthenticating == false) #expect(externalURLOpener.openedURLs.isEmpty) #expect(transports.isEmpty) From 68cede191118265aa7112efd9740e4a4f9e87df0 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:36:14 +0900 Subject: [PATCH 5/5] test(app-server): await review item delivery --- .../CodexAppServerKitTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift index 5d55aa4..1123ebe 100644 --- a/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift +++ b/Tests/CodexAppServerKitTests/CodexAppServerKitTests.swift @@ -2342,10 +2342,18 @@ struct CodexAppServerKitTests { var eventIterator = review.events.makeAsyncIterator() let initialEvent = try #require(try await eventIterator.next()) #expect(initialEvent == .snapshot(review.initialTurn)) + let completedItemsObserved = CodexAppServerTestGate() let eventsTask = Task { var events = [initialEvent] + var completedItemIDs: Set = [] while let event = try await eventIterator.next() { events.append(event) + if case .itemCompleted(let item, _) = event { + completedItemIDs.insert(item.id) + if completedItemIDs == ["command-1", "reasoning-1", "tool-1", "file-1"] { + await completedItemsObserved.open() + } + } } return events } @@ -2410,6 +2418,9 @@ struct CodexAppServerKitTests { ) ) ) + try await withTimeout { + try await completedItemsObserved.wait() + } try await transport.emitServerNotification( method: "turn/completed", params: TurnCompletedParams(