diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d099a017..0b60cc7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,16 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +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: - package-tests: - name: Package Tests (macOS) + api-contract: + name: API Contract (macOS) runs-on: macos-26 timeout-minutes: 10 - env: - XCODE_MAJOR: '26' steps: - name: Check out repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -36,49 +39,170 @@ jobs: - name: Verify GitHub Actions pinning run: ruby scripts/verify-github-actions-pinning.rb - - name: Resolve latest stable Xcode + - name: Verify pinned Xcode + run: scripts/verify-ci-xcode.sh + + - name: Verify TextTransitions public API + run: scripts/verify-text-transitions-api.sh + + package-build: + name: Package Test Build (macOS) + runs-on: macos-26 + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Verify pinned Xcode + id: toolchain + run: scripts/verify-ci-xcode.sh + + - name: Restore SwiftPM build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .build + key: spm-build-v1-debug-swiftbuild-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved') }}-${{ github.sha }} + restore-keys: | + spm-build-v1-debug-swiftbuild-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved') }}- + + - name: Restore source modification times + run: scripts/restore-source-mtimes.sh + + - name: Build package tests + run: swift build --build-tests --build-system swiftbuild + + package-tests: + name: Package Tests (macOS, ${{ matrix.shard }}) + needs: package-build + runs-on: macos-26 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - shard: CodexReviewKitTests + filter: CodexReviewKitTests + skip: '' + - shard: CodexReviewAppServerTests + filter: CodexReviewAppServerTests + skip: '' + - shard: CodexReviewMCPServerTests + filter: CodexReviewMCPServerTests + skip: '' + - shard: CodexReviewHostTests-a-d + filter: '^CodexReviewHostTests\.CodexReviewHostTests/liveStore[A-D]' + skip: '' + - shard: CodexReviewHostTests-e-j + filter: '^CodexReviewHostTests\.CodexReviewHostTests/liveStore[E-J]' + skip: '' + - shard: CodexReviewHostTests-k-r + filter: '^CodexReviewHostTests\.CodexReviewHostTests/liveStore[K-R]' + skip: '' + - shard: CodexReviewHostTests-remaining + filter: '^CodexReviewHostTests\.' + skip: '^CodexReviewHostTests\.CodexReviewHostTests/liveStore[A-R]' + - shard: ReviewUITests + filter: ReviewUITests + skip: '' + - shard: remaining + filter: '' + skip: 'CodexReviewKitTests|CodexReviewAppServerTests|CodexReviewMCPServerTests|CodexReviewHostTests|ReviewUITests' + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Verify pinned Xcode + id: toolchain + run: scripts/verify-ci-xcode.sh + + - name: Restore built package tests + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .build + key: spm-build-v1-debug-swiftbuild-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved') }}-${{ github.sha }} + fail-on-cache-miss: true + + - name: Run package test shard + env: + TEST_FILTER: ${{ matrix.filter }} + TEST_SKIP: ${{ matrix.skip }} run: | - xcode_app="$(ruby <<'RUBY' - xcode_major = ENV.fetch("XCODE_MAJOR") - candidates = Dir["/Applications/Xcode_#{xcode_major}*.app"].select { |path| File.directory?(path) } - - def pre_release_xcode_path?(path) - labels = [path, File.realpath(path)].map { |candidate| File.basename(candidate).downcase } - labels.any? do |label| - label.match?(/beta|release[ _-]?candidate|(?:^|[_. -])rc(?:$|[_. -])/) - end - end - - candidates = candidates.reject do |path| - pre_release_xcode_path?(path) - end - - def version_components(path) - version = File.basename(path)[/Xcode_(\d+(?:\.\d+)*)/, 1] - version.to_s.split(".").map(&:to_i) - end - - selected = candidates.max_by { |path| version_components(path) } - abort("No stable Xcode #{xcode_major} installation found") unless selected - puts selected - RUBY - )" - echo "DEVELOPER_DIR=${xcode_app}/Contents/Developer" >> "$GITHUB_ENV" - echo "Resolved Xcode: ${xcode_app}" - - - name: Show Xcode environment + args=( + --skip-build + --build-system swiftbuild + --no-parallel + ) + [[ -z "$TEST_FILTER" ]] || args+=(--filter "$TEST_FILTER") + [[ -z "$TEST_SKIP" ]] || args+=(--skip "$TEST_SKIP") + swift test "${args[@]}" + + monitor-build: + name: ReviewMonitor Test Build (macOS) + runs-on: macos-26 + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Verify pinned Xcode + id: toolchain + run: scripts/verify-ci-xcode.sh + + - name: Restore ReviewMonitor build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .ci-build/review-monitor-derived-data + key: review-monitor-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved', 'Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/**') }}-${{ github.sha }} + restore-keys: | + review-monitor-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved', 'Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/**') }}- + + - name: Restore source modification times + run: scripts/restore-source-mtimes.sh + + - name: Build ReviewMonitor tests run: | - xcodebuild -version - xcodebuild -showsdks + xcodebuild build-for-testing \ + -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj \ + -scheme CodexReviewMonitor \ + -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath .ci-build/review-monitor-derived-data \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + monitor-tests: + name: ReviewMonitor Tests (macOS) + needs: monitor-build + runs-on: macos-26 + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Verify pinned Xcode + id: toolchain + run: scripts/verify-ci-xcode.sh - - name: Run Swift package tests - run: swift test --build-system swiftbuild --no-parallel + - name: Restore built ReviewMonitor tests + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .ci-build/review-monitor-derived-data + key: review-monitor-build-v1-${{ runner.os }}-${{ runner.arch }}-${{ steps.toolchain.outputs.cache-id }}-${{ hashFiles('Package.swift', 'Package.resolved', 'Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/**') }}-${{ github.sha }} + fail-on-cache-miss: true - - name: Run monitor app tests + - name: Run ReviewMonitor tests run: | - xcodebuild test \ + xcodebuild test-without-building \ -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj \ -scheme CodexReviewMonitor \ -destination 'platform=macOS,arch=arm64' \ + -derivedDataPath .ci-build/review-monitor-derived-data \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO diff --git a/Docs/design-audit-2026-07-10.md b/Docs/design-audit-2026-07-10.md new file mode 100644 index 00000000..1b78032a --- /dev/null +++ b/Docs/design-audit-2026-07-10.md @@ -0,0 +1,726 @@ +# CodexKit / CodexReviewKit 設計・API 監査(2026-07-10) + +| 項目 | 内容 | +|---|---| +| 対象 | CodexKit `3f6216c`(CodexAppServerKit / CodexDataKit / CodexAppServerKitTesting)、CodexReviewKit `6c1b432` | +| 一次情報 | upstream codex `8347b8d`(固定 worktree: `/Users/kn/Dev/checkout/codex-8347b8d`)、CodexKit `3f6216c`(`/Users/kn/Dev/checkout/CodexKit`) | +| 方法 | owner map → protocol / lifecycle / identity invariant → static trace。初期 mapper の raw 130 件を canonical 89 件へ統合し、初期 high+medium 53 件を adversarial に再検証 | +| 検証ステータス | 初期 high+medium 53 件と追加 low 1 件を再判定: **CONFIRMED 49 / PLAUSIBLE 1 / REFUTED・NOT ADOPTED 4**。採用 50 件は Appendix A(high 10 / medium 33 / low 7)、low 35 件は未検証のまま Appendix B | + +読み方: 本文はクラスタ単位の判断、Appendix A は id 付きの証拠と failure trace である。パス表記は `CK:` = CodexKit、`CRK:` = 本 repo、`UP:` = upstream codex。severity は実装の存在ではなく、確認できた到達性と利用者影響で再評価した。 + +## 1. 結論 + +PR #16(2026-07-02)で問題だった generation 境界と observation multicast は解消済みである。`withThreadEventGeneration` が SDK の generation 切替を所有する。CRK では review worker pipeline が stale-event rejection を共同所有し、`ReviewWorkerEventSource` は subscription generation と pre-enqueue guard、`consumeReviewEvents` / `ReviewNetworkRecoveryLoopState` は attemptID と post-enqueue guard を担う。 + +現状は単一の「upstream v2 終端契約の誤読」には集約できない。確認できた弱境界は次の 4 群である。 + +1. **turn outcome の分類** — upstream の server-side interrupt は `turn/completed(status=interrupted, error=nil)` だが、`CodexTurnStatus.isFailure` が `interrupted` を failure に畳み、collector / progress / DataKit に伝播する。これは最も広い cross-layer cluster である。 +2. **resource lifecycle** — router が自身を保持する Task cycle、`close()` が Task completion を await しない構造、router history と serializer lane の無期限保持に、単一 close/retention owner がない。 +3. **wire boundary** — invented login method、server request への protocol-invalid `{}` 応答、request/transport/decode error の public 型への不完全な写像、現行 v2 で emit されない compatibility route が同居する。 +4. **DataKit / Testing policy owner** — mutation ごとの local-apply/refetch 判断、同時 `load()`、fake の list semantics、手書き wire fixture が別々の source of truth を持つ。 + +CRK は raw JSON-RPC を再実装していない。public typed surface の `CodexReviewSession.events` と `cancel()` を使い、terminal aggregate の `progress` / `collect()` / `response` は使っていない(`use-highlevel-surface-bypass`)。これは aggregate 層の outcome/output 契約に摩擦がある証拠だが、SDK の高水準 API 全体が利用不能という証拠ではない。 + +`thread/closed` cluster は production P1 から外す。upstream 一般では、最後の subscription が unsubscribe または server が存続する multi-connection transport の disconnect で除去され、thread が inactive/idle になると unload する。running thread は unload しない。persisted thread は resume できるが ephemeral thread は unload 後に resume できない。CodexKit の stdio transport は unsubscribe を送らず、last connection close で app-server 自体が終了するため、pinned baseline の live connection から `.closed` は通常観測不能である。 + +## 2. upstream 契約の一次事実 + +| protocol 事実 | 根拠(UP: `codex-rs`) | CodexKit への含意 | +|---|---|---| +| 現行 v2 の turn terminal notification は `turn/completed`。結果は `turn.status`(completed / interrupted / failed / inProgress) | `app-server-protocol/src/protocol/v2/turn.rs:29-35`、`app-server/README.md` の turn lifecycle | `interrupted` と `failed` を別 outcome に保つ必要がある。Swift aggregate が return / typed outcome / throw のどれを選ぶかは SDK 契約として別途決める | +| server-side interrupt は `status=interrupted, error=nil` で完了する | `app-server/src/bespoke_event_handling.rs:1438-1459` | `isFailure` への単純集約は情報を失う。caller Task cancellation は `CancellationError` として別に扱う | +| `thread/closed` は最後の subscription が unsubscribe(または server が存続する multi-connection disconnect)で除去された後の inactive/idle unload。running 中は unload しない。persisted thread だけが後で resume 可能 | `app-server/README.md:140-162,455-477`、`thread_state.rs:542-564`、`thread_processor.rs:2607-2619`、`thread_lifecycle.rs:55-60,351-354,406-436`、`app-server/src/lib.rs:718-719,996-1016,1156-1166` | CodexKit stdio は unsubscribe を送らず、last connection close で server が終了するため `.closed` は通常観測不能。injected/future `.closed` で generation を終えること自体は整合し、turn outcome 合成だけが誤り | +| `turn/failed` と `item/updated` は一時期 schema に存在したが、現行 baseline ではなく実装から emit された証拠もない。`turn/cancelled` と `agent/message` は exact method history も確認できない | upstream history `a010c1b7fcce` → `ce35cb16b279`、現行 `protocol/common.rs` | 削除は compatibility policy を決めてから行う。fallback identity は `agent/message` だけでなく itemID 欠損 delta でも駆動するが、現行 valid v2 wire は itemID required | +| client→server login request は `account/login/start` / `account/login/cancel`。stock flow の完了は server notification `account/login/completed` | `app-server-protocol/src/protocol/common.rs:1001-1013,1705-1708`、`account.rs:68-86,124-137` | client request `account/login/complete` + `nativeWebAuthentication` は stock codex との契約にない | +| `thread/rollback` は “will be removed soon” と明記 | protocol definition の deprecation 注記 | fork/resume が replacement だとは一次情報から断定できない。依存を一箇所へ隔離し、移行仕様を upstream と確定する | +| `account/rateLimits/updated` は sparse rolling update | `app-server-protocol/src/protocol/v2/account.rs:508-515` | snapshot 置換ではなく merge owner が必要 | +| server→client request は method ごとの typed response を要求し、abort は `serverRequest/resolved` で通知される | request/response serde、`protocol/v2/notification.rs:52-57` | default `{}` と resolved 無視は request lifecycle contract を破る | + +## 3. Owner map + +### CodexKit + +| 責務 | 現状 | 破られた invariant | 推奨 owner | +|---|---|---|---| +| turn outcome | `CodexTurnStatus.isFailure` と各 sequence / DataKit の分岐 | completed / interrupted / failed と caller cancellation が保存されず、unknown/nil/nonterminal status の扱いも暗黙 | public terminal-outcome 型。completed / interrupted / failed に加え、unknown・nil・terminal notification 内の inProgress は typed invalid-terminal outcome として fail loud | +| request error boundary | public `CodexAppServerError` は一部で使うが、request/transport/decode error は package/private/raw 型が多い | consumer が server rejection / spawn / invalid response を安定した型で分岐できない | transport が error envelope/data を保持し、client が requestID と request/decode mapping、public start/factory が spawn/scaffold mapping を所有 | +| connection close | router → Task → router の strong cycle。stop は cancel するが completion を await しない | close 完了後に Task / transport / process が停止した証明がない | 単一 async close authority が I/O stop、Task cancel、Task completion await を所有。独立した synchronous process-terminator token を deinit backstop にする | +| replay retention | router history と serializer lane が append-only | connection lifetime が memory upper bound になる一方、late/repeated `result()` は terminal replay に依存 | raw deltasを finalized snapshotへ compactし、その snapshotも handle lifetime または documented TTL/LRU + typed expiryで bound。無期限 late resultを保証するなら外部永続 ownerが必要。laneはin-flight完了後に除去 | +| thread unload | router は current thread generation を finish(整合)するが reason は型に出さず、DataKit/CRK は turn outcome を合成 | generation end と turn success/failure は別契約 | router は必要なら typed unload reason を公開し、DataKit/adapter は outcome を合成しない。persisted resume は新 generation | +| fetch mutation policy | insert/archive/revalidate/remove/refresh が個別判断 | 同じ membership/order invariant に複数 strategy がある | query plan から導出する単一 per-mutation strategy | +| wire fixture | SDK tests / Testing target / CRK preview が手書き | fake dialect が production protocol を上書きする | Testing target の method-specific typed builders。現行 method だけを既定で提供 | + +### CodexReviewKit + +維持すべき owner: + +- domain core は transport 非依存で、UI target も CodexAppServerKit を import しない。 +- preview は `CodexAppServerTestRuntime` で transport を fake 化し、本番 data flow を通す。 +- review worker pipeline が generation boundary を所有する。`ReviewWorkerEventSource` の subscription generation / pre-enqueue guard と、consumer/recovery state の attemptID / post-enqueue guard はどちらも削除対象ではない。 +- MCP session close は `closeSession`、chat-log render は純粋 projection + document diff に集約されている。 + +弱い owner: + +- login flow の 9 state values を 8 cluster が異なる subset で協調し、app-server-scoped `authNotificationTask` は別責務として隣接する(`arch-login-state-no-owner`)。 +- `attemptID` の `"attempt-1"` default は source of truth を増やすが、review run は memory-only で production 作成経路は UUID を設定する。現状は collision bug ではなく latent migration/API hazard(`arch-attempt-id-fallback`)。 +- SDK は `CodexReviewIdentity.sourceThreadID` / `activeTurnThreadID` を持つが、CRK の Run/event plane が String に type-erase し、3 層で再導出する(`use-dual-thread-identity`)。 +- 未使用なのは `CodexReviewHost` **class** と `DirectCodexReviewStoreBackend` であり、production composition を持つ `CodexReviewHost` target 全体ではない(`arch-host-target-test-only`)。 + +## 4. Consumer-action based DX 評価 + +外部 SDK の点数や「17 契約」は証拠表を作っていないため評価根拠にしない。ここでは、CRK が public API だけで start / observe / cancel / close / recover を完遂できるかで評価する。 + +**CodexAppServerKit** は transport actor、per-thread serial lane、overload retry、replay router、`.unknown` + raw payload 保持を備え、正常系の層分離は明快である。弱いのは境界失敗時の consumer action である。 + +- request/transport/decode error の一部が public taxonomy に入らず、CRK は `localizedDescription` へ 37 箇所で還元している。一方、turn failure、collector の transport close、restart、login validation には `CodexAppServerError` が実際に使われているため「型で何も catch できない」は誤り。 +- initialize handshake と request response に deadline がない。valid な review は長時間無イベントになり得て heartbeat もないため、inter-event-gap timeout は導入しない。optional handshake/per-request deadline と caller-specified overall turn deadlineを分ける。 +- caller Task cancellation が event iteration 中に起き、stream が terminal event なしで nil 終了した経路だけ `.transportClosed` へ化け得る。turn/start request 自体の cancellation は `CancellationError` を保つ。 +- stale/no-active `turn/interrupt` error は plain `invalid_request(message)` で structured data を持たない。`CodexErrorInfo` を保存するだけでは string parse は消えず、upstream の structured interrupt error または別の typed affordance が必要である。 + +**CodexDataKit** は identity-stable な `@Observable` model を in-place 更新する。一方、API 名から想起される契約との差は明文化が必要である。 + +1. `@CodexQuery` は外部 process による thread list 変更を自動 ingest しない。これは「live query」の範囲を server event にまで広げるかという product contract の不足である。 +2. unsupported predicate/sort は query construction / SwiftUI update 経路で `preconditionFailure` になる。SwiftData の explicit `ModelContext.fetch` には throwing path があるため、少なくとも explicit load では validation error を返せる surface が望ましい。 +3. `isArchived` を含まない predicate に `archived == false` を暗黙注入する。 +4. `includePendingChanges` と public mutable server-owned fields の意味が、保存 API 不在のまま不明瞭である。 + +## 5. API 過不足 + +### stock upstream と整合しない surface + +| surface | 判断 | finding | +|---|---|---| +| `account/login/complete` + `nativeWebAuthentication` | stock codex に存在しない。fork 契約なら capability と binary pin を明記し、そうでなければ撤去 | `cov-invented-login-complete` | +| current-v2 で emit されない notification/status | `turn/failed` / `item/updated` には短命な historical schema があるため、単純な「全履歴に不存在」ではない。互換期間を決め、Testing の既定 fixture から invented dialect を除く | `cov-dead-compat-notifications` | + +### consumer story から確認できる不足 + +| 欠落 | 消費側の代償 | finding | +|---|---|---| +| stable public request/transport/decode error + `error.data` / requestID | message parse、failure category の type erase | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded` | +| optional handshake / request / overall-turn deadline | wedged binary/request を caller が分類して打ち切れない | `dx-no-timeout-story` | +| server→client request の method-specific response + resolved lifecycle | default `{}` が decode failure、abort 後 handler が残る | `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | +| explicit connection termination surface | typed notification stream failureを runtime termination signal と兼用 | `use-runtime-death-side-channel` | +| typed warning / deprecation / retry projection | raw `.unknown(CodexRawNotification)` を読めば見えるが、typed progress と CRK adapter では落ちる | `cov-error-warning-untyped` | +| external-change freshness contract | MCP read は on-demand snapshot ownerとして refresh するが、projection 不在と refresh failure が同じ nil になる | `dk-query-not-live-external`, `use-mcp-refresh-fallback` | + +`CodexTranscript.reviewOutputText` は README と実装に既にある primary review-output contract である。CRK の `reviewOutputText ?? finalAnswer ?? transcript.finalAnswer` は SDK 欠落への補償ではなく、削除可能な consumer-side redundancy(`use-review-output-location`)。outbound raw JSON-RPC escape hatch は consumer story と method lane/capability/experimental gating の設計がないため、今回の不足には数えない(§7)。 + +### 適切に無いもの + +`fs/*`、`mcpServer/*`、skills、plugins、exec/process、realtime、remoteControl 系には CRK の需要や workaround pressure がない。CRK production code に手書き JSON-RPC はなく、raw JSON は preview/test transport に限定される。`review/start` の request method・ReviewTarget・ReviewDelivery は一致するが、upstream required の `reviewThreadId` を CodexKit response DTO が optional に弱めている点は別の low-level contract gap である。 + +## 6. 修正方針 + +### P1 — public contract と lifecycle owner + +| # | 修正 | 回復する invariant | findings | +|---|---|---|---| +| 1 | public terminal-outcome 型を `completed / interrupted / failed / invalidTerminalStatus(rawStatus,response)` として定義し、collector / progress / DataKit / CRK adapter を同じ exhaustive mapping へ接続する。unknown、nil、terminal notification内のinProgressをoutput有無からsuccess/failureへ推測しない。caller Task cancellationは`CancellationError`、server interruptはtyped outcomeとして区別する | wire の分類情報を convenience/adapter layer が失わず、未知状態を誤分類しない | `conf-interrupted-is-failure`, `ask-cancel-collect-misreported`, `dx-cancel-semantics-inconsistent`, `use-highlevel-surface-bypass` | +| 2 | transport が JSON-RPC error envelope/data を保持し、`AppServerClient` が requestID と request/decode errorを写像、public start/factory が spawn/scaffold errorを写像する。optional handshake/request deadlineを各 ownerに置き、overall turn deadlineは caller policy。interrupt parse 撤去は structured server affordanceを upstream と合意 | consumer が failure category ごとの次 action を選べる | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded`, `ask-interrupt-error-string-parsing`, `dx-no-timeout-story` | +| 3 | router Task の strong edge を切り、単一 `close()` が producer/I/O stop → Task cancel → Task completion await → handle release を完了する。process group は独立 terminator tokenを deinit backstopにする。raw history は finalized snapshotへ compactし、snapshot自体も turn-handle lifetimeまたは documented TTL/LRU + typed `resultExpired` で evictする。無期限 repeated late resultを要求するなら外部 persistenceへ移す | close 後にTask/transport/processが残らず、replay契約を保ったままconnection memoryがbounded | `ask-process-leak-no-deinit`, `ask-router-history-unbounded` | +| 4 | invented login surface を撤去または fork capability として pin。server request は method-specific default decline と `serverRequest/resolved` cleanup を実装 | public API と wire method、request lifecycle が一致する | `cov-invented-login-complete`, `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | +| 5 | current `commandExecution/outputDelta` は append merge、feature-gated current `fileChange/patchUpdated` は structured snapshot replace/mergeとして既存 item metadataを保つ。legacy `fileChange/outputDelta` は compatibility policyへ隔離する。rate-limit sparse updateはlast snapshotへmerge | delta/snapshot update の semantic owner が item を壊さない | `conf-command-delta-clobbers-item`, `conf-ratelimits-replace-not-merge` | + +`thread/closed` による thread-generation completion 自体は P1 ではない。DataKit/CRK の outcome synthesis は今削除できる。将来 unsubscribe surface または multi-connection unload を扱うとき、typed end reason、persisted resume の新 generation、ephemeral unload の非 resumable 性を contract test で固定する。 + +### P2 — DataKit / Testing の再発防止 + +| # | 修正 | findings | +|---|---|---| +| 6 | DataKit mutation strategy を query plan 由来の単一 owner にし、`load()` を serial task または generation token で latest-wins にする | `dk-mutation-strategy-scattering`, `dk-unserialized-fetch-loads` | +| 7 | current-v2 と historical compatibility policy を決める。Testing の default builders は現行 method(例: `emitTurnCompleted`、`emitCommandExecutionOutputDelta`、`emitFileChangePatchUpdated`、server request)だけを提供し、legacy builderは明示namespaceへ隔離 | `cov-dead-compat-notifications`, `test-fictional-turn-failed-pins-phase-contract`, `test-handrolled-notification-schemas-drift` | +| 8 | fake を fail-fast にし、thread/list の archived/sort と実 transport の cancellation を実装する。server-request injection は in-memory fake から test 可能にする | `test-unstubbed-method-silent-success`, `test-store-ignores-archived-and-sort`, `test-fake-cancel-in-flight-returns-success`, `test-no-server-request-injection` | +| 9 | predicate/sort validation を throwing construction/load surfaceへ移し、archived default と external-change freshness scope を文書化する | `dk-predicate-runtime-crash-contract`, `dk-implicit-archived-scope`, `dk-query-not-live-external` | +| 10 | warning/deprecation/retry と connection termination を typed lifecycle/event surfaceへ投影する。raw payload は diagnostic escape として保持する | `cov-error-warning-untyped`, `use-runtime-death-side-channel` | + +### P3 — CodexReviewKit の整理 + +| # | 修正 | 条件 | +|---|---|---| +| 11 | login state を単一 `LoginSession` owner に集約し、全 exit path が同じ async terminate completion を待つ | 即時 | +| 12 | adapter 境界で `reviewOutputText` の欠損を一度だけ typed failure にし、成功側の `Completion.finalReview` を non-optional にする。3段 fallbackと store/MCP の重複検査、item-status再導出、未使用 `CodexReviewHost` class + `DirectCodexReviewStoreBackend` を削除 | 即時。class 削除は target 削除を意味しない | +| 13 | core に CRK domain の review-thread identity(source / active-turn pair)を置き、adapter が `CodexReviewIdentity` から一度だけ写像する。String 再導出を削除し、transport 非依存を保つ。presentation dedup は typed item kind + turn relation で行い、distinct semantic items を persistence で一つに潰さない | 即時に型設計、既存 decoder contract の確認後に移行 | +| 14 | `"attempt-1"` default を削除する。queued Run の nil は許しても、threadID を持つ backend Run の復元では attemptID を必須にして fail fast。resume-to-cancel は到達する consumer story を先に証明し、必要なら cancel-by-identity 後に削除。observe serialization は awaitable rebind 後に削除 | attempt default は即時、他は各 contract 後 | + +backend adapter は P1-1 の public outcome を exhaustively mapし、現行の interrupted→cancelled repair と unknown/nil/running + output→completed 推測を削除する。一方、store の `completePendingCancellationIfNeeded` は「product の cancellation request が backend terminal と競合したとき cancellation wins」という別 invariant を所有し、SDK修正後も残す。`ReviewBackendEventSession` の terminal は backend abstraction の契約であり、SDK event と同一視して全削除しない。 + +## 7. 反証・今回採用しない提案 + +- **REFUTED — `test-fake-close-clean-finish-hangs-subscribers`**: fake の `close()` は package-scoped で consumer から到達不能。public `CodexAppServer.close()` は先に `router.stop()` で stream を finish する。clean/throwing 終端の非対称は low の設計課題だが、報告された hang trace は成立しない。 +- **REFUTED — `use-item-status-rederivation`**: CodexKit `2a4d2f5` が全 terminal 経路で `itemByApplyingTerminalLifecycleStatus` を適用済み。CRK projection の再導出は live workaround ではなく削除可能な残骸である。 +- **REFUTED — `use-subscription-generation-guards`**: review worker pipeline が責務を分担している。`ReviewWorkerEventSource` は subscription generation と pre-enqueue guard、`consumeReviewEvents` / `ReviewNetworkRecoveryLoopState` は attemptID と post-enqueue guard を所有し、tests も pin する。これは owner 不在ではない。supersede reason の typed 化は追加 DX になり得るが、guard の削除理由にはならない。 +- **NOT ADOPTED — `dx-no-raw-escape-hatch`**: raw send の不存在は事実だが、CRK の consumer storyがなく、upstream request は method-specific serialization lane、capability、experimental gatingを持つ。単純な `sendRaw(method:params:)` はそれらを迂回するため、現時点の不足・「安価な追加」とは判定しない。 + +## 8. テストチェックリスト + +- [ ] server-side `turn/completed(status=interrupted,error=nil)` が typed interrupted outcome になり、failed と区別される — sibling: collector / progress / DataKit phase +- [ ] terminal notification の unknown / nil / inProgress status は typed `invalidTerminalStatus` になり、CRK adapter も output text の存在だけで `.completed` を合成しない +- [ ] caller Task cancellation が turn/start request 中と event iteration 中の双方で `CancellationError` になり、server-side interrupted と区別される +- [ ] current wire `turn/completed(status=failed,turn.error)` で `chat.phase == .failed` を pinし、fictional `turn/failed` fixture を置換する +- [ ] explicit close が router/producer Task completion と child-process reap を awaitし、二重 close も同じ completionへ収束する +- [ ] long-lived connection で raw history・finalized snapshot・serializer lane が retention policyどおり解放され、期限内の repeated late `result()` と期限後の typed `resultExpired` が一致する +- [ ] command output delta は command/cwd と既存 output を保って appendされ、feature enabled 時の fileChange patchUpdated は item metadata を保って structured snapshot を適用する +- [ ] login の success / cancel / web-session error / account-update error / runtime death / explicit close の全 exit path が fields、tasks、temporary runtime を解放する +- [ ] fake は unstubbed method で fail-fast し、thread/list の archived/sort と in-flight cancellation(cancelled waiterは `CancellationError`、late responseは破棄)を production と一致させる +- [ ] `serverRequest/resolved` が pending handler を解放し、requestUserInput/permissions/DynamicToolCall/MCP elicitation の既定 decline が valid wire shape になる +- [ ] 並行 `load()` の A→B→A completion で items/cursor が選択した latest-wins contract と一致する +- [ ] injected `.closed` / `.notLoaded` は turn success/failure を合成しない。将来 unsubscribe/multi-connection transport を実装する場合、persisted resume の新 generationと ephemeral unload の非 resumable 性を integration test で pinする +- [ ] stale old-attempt event は `ReviewWorkerEventSource` の pre-enqueue guard と consumer/recovery state の attemptID + post-enqueue guardで捨てられる(既存 regression を維持) +- [ ] review output は adapter 境界で `reviewOutputText` を一度だけ採用する。欠損は一度だけ typed failure、成功 completion は non-optional とし、final assistant message は distinct semantic item として保持する +- [ ] inline / detached review の source/active-turn identity pair が adapter → store → MCP を通して保存される +- [ ] threadID を持つ backend Run の attemptID 欠損は `"attempt-1"` を作らず fail fast する +- [ ] in-flight observation registration の cancel/rebind が awaitable completion で同期する +- [ ] CRK login teardown・review output・item status cleanup の削除後も product cancellation-wins arbitration が維持される + +**観測性**: `error.willRetry` と `deprecationNotice` は raw `.unknown` から読めるが、typed progress / CRK adapter では失われる。connection termination、retry、deprecation をそれぞれ意味のある lifecycle/event 境界へ投影する。 + +## 9. 監査の限界 + +- Appendix B の low 35 件は adversarial 検証を通しておらず、本文の確定判断には使わない。 +- ReviewChatLogUI / ReviewUI は core state と projection を精読したが、全 view path の runtime probe は行っていない。 +- `thread/closed` の current unreachability は CodexKit stdio と upstream shutdown の static trace に基づく。将来 unsubscribe または server が存続する multi-connection transport を使う場合は再監査が必要。 +- `TextTransitions`、`scripts/`、`Tools/` は対象外。 + +--- + +## Appendix A: 検証済み findings(再評価後、50 件) + +各項目: 検証 verdict / 調整後 severity / 位置 / 内容 / 証拠。kind: bug = 実挙動の欠陥、protocol-mismatch = upstream 契約との不一致、api-design / usability = 設計判断、coverage-gap = API 過不足、test-gap = テスト欠落、workaround = 消費側補償、architecture = 構造。 + +### `arch-login-state-no-owner` — Login/auth session teardown invariant has no owner: 9 login-state values are coordinated across 8 reset clusters + +**CONFIRMED / high / architecture** — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:218` + +Login-flow state (`loginChallenge`, `loginBackend`, `loginAppServer`, `loginCodexHomeURL`, `loginActivation`, `isWaitingForLoginAccountUpdate`, `activeAuthenticationSession`, `authenticationTask`, `loginNotificationTask`) is coordinated by manually nil-ing/cancelling different subsets across 8 clusters: the 7 complete/exit paths originally identified plus the partial reset in `startLogin`. `loginActivation` participates in the state machine but is not reset like the other fields; `authNotificationTask` is a tenth nearby field but is app-server-scoped. `PendingLoginRuntimeCleanup` already groups part of runtime cleanup, yet does not own the whole login session or all exit paths. Any new exit path must reproduce ordering and ownership or retain an isolated app-server process / temporary CODEX_HOME. + +- 証拠: LiveCodexReviewStoreBackend.swift:218-227 fields; reset clusters :670-702, :885-888, :915-940, :974-991, :1033-1062, :1300-1321, :1338-1384, :1559-1578 — same invariant, different order/subset. +- 修正の方向: Extract a LoginSession type (challenge + runtime + web session + tasks) with a single terminate(reason:) as the only teardown path; the backend holds at most `var loginSession: LoginSession?`. + +### `ask-cancel-collect-misreported` — Task cancellation during event iteration can surface transportClosed instead of CancellationError + +**CONFIRMED / high / bug** — `CodexKit:Sources/CodexAppServerKit/CodexTurnSequences.swift:490` + +When caller cancellation happens after the turn exists and while `collect()` is iterating `turn.events`, the `AsyncThrowingStream` iterator can return nil. The collector then reaches its unconditional `CodexAppServerError.transportClosed`, losing the caller-cancellation category. Cancellation during the earlier turn/start request still propagates `CancellationError`; the defect is not every cancellation path. CRK checks `Task.isCancelled` around its typed event loop because terminal-less iteration and server-side interruption need separate handling. + +- 証拠: CodexTurnSequences.swift:466-491 loop then `throw CodexAppServerError.transportClosed`; CodexDomainTypes.swift:1994-2008 collect() never converts loop-exit to CancellationError; consumer workaround CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:753-759. +- 失敗トレース: 1) Consumer awaits `thread.respond(to:)` = `streamResponse().collect()` (CodexThreadOperations.swift:74). 2) `collect()` awaits `turn.result()`, whose collector iterates `turn.events` (CodexTurnSequences.swift:468). 3) Caller cancels after iteration began; the cancellation handler starts an ownerless unstructured `Task { try? await turn.interrupt() }` and the iterator can return nil. 4) The loop exits without `.completed` / `.failed` and line 490 throws `.transportClosed`. 5) Caller receives transport failure for this cancellation path; `waitForCancelledResponse` has the same terminal-less-loop shape. +- 修正の方向: A shared stream-exit classifier used by collector/result and `waitForCancelledResponse` owns the distinction: caller Task cancelled → `CancellationError`; observed transport termination → `transportClosed`; neither but terminal missing → typed invalid stream end. Do not use a typed server `.cancelled` outcome for caller Task cancellation. + +### `ask-process-leak-no-deinit` — Dropping CodexAppServer without close() leaks the spawned codex child process + +**CONFIRMED / high / bug** — `CodexKit:Sources/CodexAppServerKit/AppServerProcessTransport.swift:153` + +Process termination is reachable only via `close()` / stdout EOF (`closeTransport`). In addition, the router stores `routerTask` while that Task strongly captures the router, forming `router → task → router → client → transport`. Dropping the public server without explicit close therefore does not make the transport unreachable; neither object deallocation nor stdin-EOF can provide cleanup. The child runs in its own process group and reaping is only performed by `terminateAndWait`. + +- 証拠: AppServerProcessTransport.swift:153-172 close/closeTransport; :662-663 process group; CodexAppServerNotificationRouter.swift:19,84-97 stored Task and strong capture; :168-171 stop cancels without awaiting; CodexAppServer.swift:216-223 explicit close. +- 失敗トレース: 1) CodexAppServer.init spawns codex app-server in its own process group (AppServerProcessTransport.swift:60-111, :662-663) and starts the router (CodexAppServer.swift:176-179). 2) router.start() creates the routerTask retain cycle (CodexAppServerNotificationRouter.swift:19,:89-97). 3) Consumer recreates its container (login change / test forgets close()) and drops the last CodexAppServer reference without calling close() (CodexAppServer.swift:220-223, the sole teardown). 4) No deinit exists anywhere in the target; terminateAndWait is never invoked; the router/client/transport chain stays resident and the codex child keeps running. 5) Each recreation adds one orphaned codex process plus one leaked actor graph. +- 修正の方向: Primary owner is explicit async close: break the Task strong edge, signal producer/I/O stop, cancel the stored Task, and await its completion before releasing client/transport. Separately keep the pid/process-group capability in a synchronous terminator token independent of the actor graph and use its deinit only as a best-effort signal/log backstop. deinit must not own async reap completion. + +### `ask-router-history-unbounded` — Notification router event history grows without bound for the connection lifetime + +**CONFIRMED / high / bug** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:20` + +turnHistoryByTurnID, threadHistoryByThreadID and threadIDByTurnID are append-only with no eviction owner; every turn-scoped event (including full rawPayload Data) is stored twice, and RequestSerializer.lanes also only grows. History is load-bearing (replay for late subscribers, waitForCancelledResponse) but nothing prunes it after a turn is terminal, so a long-lived consumer app accretes memory proportional to every delta ever streamed. + +- 証拠: CodexAppServerNotificationRouter.swift:20-22 dictionaries; :302 and :315 appends; grep shows no removeValue on any of the three maps. AppServerClient.swift:208-215 lane(for:) inserts, never removes. +- 失敗トレース: 1) Consumer keeps one CodexAppServer for app lifetime (CodexReviewHost pattern) and runs reviews continuously. 2) Every streamed notification with a turnId is decoded and appended to turnHistoryByTurnID (CodexAppServerNotificationRouter.swift:302) and, when threadId resolves, again to threadHistoryByThreadID via appendThreadEvent (:297,:314-315); item events carry full rawPayload Data (:904, :1400). 3) turn/completed arrives; isTerminalTurnEvent (:512-520) only triggers finishTurnSubscribers (:308-310); both history entries for the finished turn remain forever. 4) thread/closed (:333-335) likewise removes no history. 5) After N reviews × thousands of delta events each, resident memory holds two copies of every delta ever streamed plus one SerialLane per thread (AppServerClient.swift:208-215) until process exit. +- 修正の方向: Router owns retention without breaking late/repeated replay. At terminal, compact raw deltas into a finalized response/transcript/terminal snapshot, then bound snapshot count by turn-handle lifetime or documented TTL/LRU with typed `resultExpired`. If indefinite repeated `result()` is a requirement, move finalized results to an external persistence owner rather than keeping them in connection memory. Remove serializer lanes after in-flight work completes. + +### `conf-interrupted-is-failure` — Collector, progress, and DataKit classify server-side interrupted outcomes as failures + +**CONFIRMED / high / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:1878` + +Upstream emits one turn-terminal method, `turn/completed`, and server-side interrupt completes as status `interrupted` with no `TurnError`. `CodexTurnStatus.isFailure` nevertheless returns true for `.interrupted` and the aggregate layers key off it: collector/respond throw `turnFailedWithResponse`, progress yields `.failed`, and DataKit marks the chat failed on the event and on refresh. `waitForCancelledResponse` already distinguishes `.interrupted/.cancelled` and returns normally, demonstrating that the inconsistency is limited to the collector/progress/DataKit paths rather than every SDK layer. Whether another Swift aggregate returns `CodexResponse`, a typed outcome, or a typed interruption is an API choice. + +- 証拠: CodexDomainTypes.swift:1878-1885 `isFailure`,2088-2112 contrasting cancellation wait; CodexTurnSequences.swift:303-309,440-447,477-487; CodexModel.swift:1145-1151,2368-2372; upstream turn.rs:29-35 and bespoke_event_handling.rs:1438-1459; consumer AppServerCodexReviewBackend.swift:626-631. +- 失敗トレース: (1) Explicit `CodexResponseStream.cancel()` or another server-side interrupt leads to `turn/completed {status:"interrupted", error:null}`. (2) Router decodes `.completed(response.status=.interrupted)`. (3) Collector/result checks `isFailure` and throws `turnFailedWithResponse`; progress yields `.failed`. (4) DataKit marks the chat failed on the event and refresh. (5) CRK's adapter re-splits the interrupted status. This trace is distinct from cancelling the caller Task that awaits collect. +- 修正の方向: A public terminal-outcome type owns `completed | interrupted | failed(TurnError) | invalidTerminalStatus(rawStatus,response)`. Collector/progress/DataKit switch exhaustively over it; unknown/nil/inProgress terminal payloads fail loud without guessed classification, and caller Task cancellation remains `CancellationError`. Choose the aggregate return/throw shape at an API design gate. + +### `cov-invented-login-complete` — Native web-auth login path (account/login/complete + nativeWebAuthentication) is invented; upstream never had it in any revision + +**CONFIRMED / high / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:2285` + +CodexKit's public 3-step native login sends account/login/start with an extra nativeWebAuthentication:{callbackUrlScheme} field, decodes it back from the response, and completeLogin sends method account/login/complete. None of this exists upstream at HEAD 8347b8d and `git log --all -S` shows it never existed. Stock codex completes the flow by emitting `account/login/completed`; there is no client completion request. Against a stock server, the extra field is silently ignored and the response never echoes nativeWebAuthentication, so the consumer's ASWebAuthenticationSession callback-completion gate never engages; the unknown completion request fails as `invalid_request` (-32600) on this baseline. If a patched codex build is intentionally targeted, that dependency is undocumented and unpinned. + +- 証拠: AppServerRequests.swift:2285 method, :2144-2162 `NativeWebAuthentication` + Params; CodexAppServer.swift:799-812,855-862; upstream common.rs:1001-1013 and account.rs:68-86,124-137; upstream history `git log --all -S`; consumer LiveCodexReviewStoreBackend.swift:911,961; `Sources/CodexAppServerKit/README.md:414-421`. +- 失敗トレース: Against a stock codex app-server: (1) CodexAppServer.loginChatGPT(nativeWebAuthentication:) sends account/login/start with extra field nativeWebAuthentication:{callbackUrlScheme} (CodexAppServer.swift:799-812; AppServerRequests.swift:2162,2255); (2) upstream deserializes LoginAccountParams::Chatgpt which has no such field (account.rs:68-86) — extra field silently ignored; (3) response is {type:"chatgpt", loginId, authUrl} only, so native callback data decodes nil; (4) consumer callback-completion gate remains false; (5) `completeLogin` sends unknown `account/login/complete`, which pinned app-server maps to `invalid_request` (-32600) in message_processor.rs:91-99/error_code.rs:3-6. +- 修正の方向: Wire layer owns method-name truth: delete the invented surface, or document and pin the fork as the supported server and gate the API on a capability check — a nonexistent method must not be a public API's only completion path. + +### `cov-rollback-deprecated` — Review restart, rollback(turnCount:) and revertTranscript are built on thread/rollback, which upstream marks 'will be removed soon' + +**CONFIRMED / high / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:510` + +Three public behaviors depend on thread/rollback: CodexThread.rollback(turnCount:), restartPreparedReview's mandatory rollback of the interrupted turn, and transcriptErrorHandlingPolicy .revertTranscript (CodexResponseStream.handleFailure). Upstream: 'DEPRECATED: thread/rollback will be removed soon.' The consumer's core review-recovery flow calls prepareReviewRestart/restartPreparedReview; when upstream removes the method, restart will fail at runtime mid-recovery after the turn was already interrupted. The future error code is not yet a contract. `deprecationNotice` is retained only as raw `.unknown`; typed progress and CRK's adapter do not surface it. + +- 証拠: CodexAppServer.swift:505-513 rollback in restart; CodexThreadOperations.swift:335-339; CodexDomainTypes.swift:2127-2135,2185-2193 revert policy; AppServerRequests.swift:1469 method; upstream thread.rs:1045 deprecation, common.rs:616; consumer CodexReviewStoreReviews.swift:676,744. +- 修正の方向: Isolate rollback behind one internal seam and define restart/revert semantics when it disappears. Fork/resume-based editing is a candidate inference, not a documented replacement; confirm the migration contract with upstream. Surface `deprecationNotice` as a typed event so consumers see the removal signal. + +### `dx-error-taxonomy-uncatchable` — Request/transport/decode failures often escape the public error taxonomy + +**CONFIRMED / high / api-design** — `CodexKit:Sources/CodexAppServerKit/JSONRPC.swift:33` + +Requests rethrow package-scoped `JSONRPC.Error`, process launch can throw private `AppServerProcessTransportError`, and response decode can throw raw `DecodingError`. Several `CodexAppServerError` cases have no throw site, and overload retry exhaustion rethrows the raw JSON-RPC error. Graceful `router.stop()` also finishes event streams with `JSONRPC.Error.closed`. This does not make the entire public error type dead: turn failures, collector transport close, restart, and login validation do use `CodexAppServerError`. The actual gap is that request rejection, spawn failure, invalid response, and graceful/abnormal termination cannot be exhaustively discriminated by public type. + +- 証拠: JSONRPC.swift:3,33-48 package enum; AppServerClient.swift:115-134; AppServerProcessTransport.swift:873 private error; CodexDomainTypes.swift:2839-2841; CodexAppServer.swift:1105-1133; router stop CodexAppServerNotificationRouter.swift:168-172; CRK has 37 `localizedDescription` reductions. +- 修正の方向: Split ownership by information availability: transport preserves JSON-RPC envelope/data and terminal reason; AppServerClient maps request/decode failures and attaches requestID; public start/factory maps private spawn/scaffold failures before a client exists. Delete or wire dead cases, and define graceful stream finish separately from abnormal close. + +### `dx-no-timeout-story` — No handshake/request/overall-turn deadline surface or dedicated timeout error + +**CONFIRMED / high / api-design** — `CodexKit:Sources/CodexAppServerKit/AppServerClient.swift:89` + +CodexAppServerKit has no initialize-handshake or request-response deadline. `CodexAppServer.init` can wait indefinitely for initialize, and each request continuation is resolved only by a response, cancellation, or transport close. Aggregate turn waits also have no caller-supplied overall deadline. Existing CRK timeouts are different contracts: product terminal wait, MCP long-poll/re-await, runtime shutdown drain, and retry backoff; they are not compensators for one missing SDK timer. + +- 証拠: AppServerClient.swift:89-137; AppServerProcessTransport.swift:113-131; CodexDomainTypes.swift:404-441. CRK contracts: ReviewObservationAwaiter.swift:40-56, CodexReviewStoreCancellation.swift:243-254, CodexReviewStoreReviews.swift:34-59. CodexKit README notes valid long quiet reviews and upstream exposes no heartbeat. +- 修正の方向: Configuration owns an optional handshake deadline; AppServerClient owns optional per-request response deadlines; aggregate turn APIs accept a caller-specified overall deadline. Do not add an inter-event-gap deadline without a heartbeat contract. + +### `test-store-ignores-archived-and-sort` — Store-backed thread/list ignores archived, sortKey and sortDirection — parameters DataKit always sends and whose serialization is itself pinned by tests + +**CONFIRMED / high / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:706` + +`filteredThreadSnapshots` filters only cwd/modelProviders/sourceKinds/searchTerm. With the store, archived:true and archived:false queries return identical membership and DataKit stamps each returned model with the query scope. Ordering is initial snapshot order with upserted items moved to the front, not the requested sortKey/sortDirection nor necessarily upstream's default created_at descending. Consumer previews hand-stub thread/archive and track archived IDs themselves. + +- 証拠: CodexAppServerTestRuntime.swift:68,706-754; request serialization CodexAppServer.swift:657; DataKit stamping CodexFetchRequest.swift:1134-1139; upstream `app-server-protocol/src/protocol/v2/thread.rs:1077-1094`; consumer workaround ReviewMonitorPreviewAppServerRuntime.swift:335-341. +- 失敗トレース: 1) Test seeds store with threads T1,T2 and wires stubThreads (CodexAppServerTestRuntime.swift:569-585). 2) DataKit archive view issues thread/list with archived:true (CodexModelContext.swift:2395-2399 -> CodexAppServer.swift:657). 3) Store's listThreadResponse -> filteredThreadSnapshots ignores request.archived (CodexAppServerTestRuntime.swift:706-754) and returns T1,T2. 4) DataKit stamps both isArchived=true (CodexFetchRequest.swift:1139). 5) The archived:false query returns the identical membership stamped false — archived and non-archived views show the same threads, contradicting v2/thread.rs:1091-1094. Sort: a sortKey:created_at/desc request returns raw snapshotOrder (:68) regardless of the requested key/direction. +- 修正の方向: CodexAppServerTestThreadStore owns the full thread/list contract it advertises: honor archived scope, sort by requested key/direction with upstream defaults, and back archive/unarchive/delete mutations so consumers stop re-implementing them. + +### `use-item-identity-text-dedup` — UI uses normalized text and raw payload kind to choose a presentation among distinct transcript items + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:624` + +Upstream intentionally emits `exitedReviewMode` and then a final `agentMessage`; they are distinct semantic items even when their text matches. DataKit must preserve both. CRK chooses a less repetitive UI presentation using `ReviewOutputKey`, a `PendingReasoningMirrors` pairing state machine keyed by scopeID + trimmed text, and `RawPayloadKind` decoded from `item.rawPayload` because the typed model lacks the discriminator needed by that presentation policy. The churn is real, but the root is not “one persisted logical item lacks a stable ID.” SDK fallback-message identity is a separate compatibility subsystem for invalid/missing item IDs. + +- 証拠: ReviewMonitorCodexChatLogProjection.swift:94-107,137-141,258-314 output dedup; :619-651 PendingReasoningMirrors; :316-323,653-674 RawPayloadKind rawPayload decode; SDK twin CodexModel.swift:53-58,926-945,1323-1332; git log --follow fix churn. +- 修正の方向: Preserve both semantic items in DataKit. Expose typed item kind and turn/review relation so the UI projection can choose a presentation without raw JSON or text equality; dedup remains presentation-owned. + +### `use-terminal-cascade` — SDK outcome adaptation and product cancellation arbitration are conflated as one workaround cluster + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:812` + +The backend adapter reclassifies CodexKit's `interrupted/cancelled` status as CRK `.cancelled`; that part compensates for `conf-interrupted-is-failure`. Two adjacent mechanisms have different owners and must not be deleted with it: `ReviewBackendEventSession` provides exactly one terminal for the backend abstraction, and the store's `completePendingCancellationIfNeeded` enforces product policy that an accepted cancellation request wins a race with a backend terminal. The latter is called at 6 sites. + +- 証拠: CodexReviewStoreReviews.swift:685,702,706,729,735,812 calls and :843 definition; ReviewBackendEventSession.swift:124-127,144-146; AppServerCodexReviewBackend.swift:627-633; SDK CodexDomainTypes.swift:1878-1882. +- 修正の方向: After CodexKit exposes a typed interrupted outcome, delete only the adapter's status repair. Keep the backend's single-terminal contract and the store's cancellation-wins arbitration unless their product requirements change. + +### `arch-attempt-id-fallback` — "attempt-1" defaults create a latent second source of attempt identity + +**CONFIRMED / low / api-design** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:349` + +Attempt identity gates event consumption and session lookup, yet three construction/decoding paths can default it to `"attempt-1"`. `reviewEventSession(for:)` is get-or-create and registration mutates the thread→attempt maps. Those facts create a latent collision hazard if a legacy/migrated record without identity is ever restored beside a live attempt. However, current review runs are memory-only, production Run creation supplies a UUID, and `applyBackendRun` stores threadID and attemptID together. No relaunch/persistence decode path into the store was found, so the original stale-record collision trace is not a current production bug. Get-or-create can also be intentional for runtime restoration. + +- 証拠: AppServerCodexReviewBackend.swift:349-382 get-or-create/registration; CodexReviewTypes.swift:239,254 defaults; CodexReviewStoreReviews.swift:234-242 `applyBackendRun`, :906 reconstruction, :1027-1029 gate; CodexReviewStore.swift:11 memory-only state. +- 修正の方向: Remove the sentinel now. A queued Run may have no backend identity, but reconstruction of a backend Run with threadID requires a real attemptID and fails fast if it is absent. Keep get-or-create restoration semantics separate; do not turn every unknown attempt into no-op. + +### `arch-closed-maps-to-failed` — Adapter contains a latent `.closed` / `.notLoaded` to failure mapping + +**CONFIRMED / low / protocol-mismatch** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:584` + +`AppServerTypedReviewEventAdapter` maps `.closed` and `statusChanged(.notLoaded)` to a user-visible failure. The mapping is semantically wrong for an unload/status event, but no current user-visible failure trace was established: CodexKit does not send unsubscribe, upstream does not unload a running turn, and when unload occurs after a terminal event `BackendReviewEventMailbox.terminal` drops the later failure. This is latent cleanup, not a confirmed production failure. + +- 証拠: AppServerCodexReviewBackend.swift:574-575,583-584; BackendReviewEventMailbox append guard in CodexReviewBackend.swift; ReviewBackendEventSession.swift:44,124,133-137; upstream thread_lifecycle.rs:351-354,406-436. +- 修正の方向: Remove success/failure synthesis from `.closed` / `.notLoaded`. Model unload/generation end separately; persisted resume starts a new generation, while ephemeral unload is not resumable. Do not reclassify unload as cancellation/interruption. + +### `arch-host-target-test-only` — CodexReviewHost class + DirectCodexReviewStoreBackend have only test callers and duplicate Live adaptation + +**CONFIRMED / medium / architecture** — `CodexReviewKit:Sources/CodexReviewHost/CodexReviewHost.swift:45` + +CodexReviewHost and its private DirectCodexReviewStoreBackend have no production callers (the app composes via CodexReviewStore.makeLiveStore); only CodexReviewHostTests use them. DirectCodexReviewStoreBackend re-implements the Live backend's snapshot/auth adaptation without persistence — two maintained copies already showing copy-drift: the dead ternary `selectedAccount == nil ? .signedOut : .signedOut` appears in both. + +- 証拠: grep CodexReviewHost( → only CodexReviewHostTests.swift:101,116,150; app composition CodexReviewMonitorApp.swift:288; duplicated mappings CodexReviewHost.swift:81-101,246-291 vs LiveCodexReviewStoreBackend.swift:590-613,1102-1163,1641-1651; dead ternary CodexReviewHost.swift:155,160 and LiveCodexReviewStoreBackend.swift:697,1161. +- 修正の方向: Delete the unused class and its private backend, then test the store against a CodexReviewTesting fake of `CodexReviewBackend`; alternatively promote the class to a real headless composition root. This finding does not justify deleting the `CodexReviewHost` target, which contains the production Live backend. + +### `arch-testing-forwarder-chains` — 107/124 ForTesting accessors are mechanically forwarded through UI layers + +**CONFIRMED / medium / test-gap** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift:351` + +ReviewMonitorCodexChatLogTarget exposes 107 testing accessors, largely forwarding probes to `logScrollView`; ReviewMonitorTransportViewController exposes 124, including its own probes plus another forwarding layer. Every new leaf capability requires mechanical wrapper edits and hides the real bind/clear/render contract. + +- 証拠: ReviewMonitorCodexChatLogTarget.swift:351-861; ReviewMonitorTransportViewController.swift:205-771, including its own placeholder probes at :235-245. +- 修正の方向: Expose the leaf inspection object once (DEBUG-only Inspector at each level, or let tests reach the scroll view directly) so probes are defined in exactly one place. + +### `ask-interrupt-error-string-parsing` — turn/interrupt correctness depends on parsing upstream error message strings, plus a fixed 5x50ms retry that also fires for already-finished turns + +**CONFIRMED / medium / workaround** — `CodexKit:Sources/CodexAppServerKit/CodexThreadOperations.swift:513` + +`interruptCodexTurn` discovers a conflicting active turn by splitting a plain server error message on `" but found "` and retries messages containing `"no active turn"` + `"interrupt"`. When turnID is nil, CodexKit sends `""`; upstream treats that as a startup interrupt which bypasses the expected-turn check, not as a mismatch probe. The string discovery path is used only for a non-empty stale turnID. Upstream returns plain `invalid_request(message)` with no structured `error.data` for stale/no-active cases, so preserving existing `CodexErrorInfo` alone cannot remove the parse. A just-finished turn also triggers the fixed 5×50ms retry and then rethrows. + +- 証拠: CodexThreadOperations.swift:465-532; upstream `app-server/src/request_processors/turn_processor.rs:904,1352,1358,1364-1373,1389`. +- 失敗トレース: 1) Turn finishes; UI cancel races it: CodexResponseStream.cancel() → interruptCodexTurn (CodexThreadOperations.swift:466). 2) Server replies invalid_request 'no active turn to interrupt' (turn_processor.rs:1370-1373). 3) isExpectedTurnNotActive matches (:524-532) → sleep 50ms, retry; repeats 5 times (~250ms+ RPC latency) (:472-481,:499-500). 4) Attempt 5: retry gate fails; message has no ' but found ' so activeTurnID is nil → `throw error` (:482-485) — the untypeable package JSONRPC.Error surfaces for what is semantically an already-cancelled success. Separately, any upstream rewording of these format! strings silently breaks both the retry match and active-turn redirect. +- 修正の方向: Short-circuit locally known terminal turns as idempotent cancellation, but do not infer unknown server state from absence of history. Ask upstream for structured stale/no-active/active-turn identity data or a typed cancel-by-identity affordance; only then delete message parsing. + +### `ask-thread-closed-terminal` — Router ends the current thread generation on unload without exposing a typed end reason + +**CONFIRMED / low / api-design** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:333` + +The router finishes thread subscribers on `.closed` and records it as the current generation's terminal thread event. That is compatible with upstream unload: no further events arrive for that loaded generation. The API-design gap is that review progress/event consumers get an undifferentiated end rather than a typed unload/generation-end reason; the router itself does not synthesize turn completion/failure. On the pinned CodexKit stdio baseline, no unsubscribe is sent and last connection close terminates the app-server, so an observable `.closed` is normally unreachable. `CodexResponseCollector` reads a turn stream and is not directly ended by `.closed`. + +- 証拠: Router :333-335,522-527; CodexTurnSequences.swift:155-157,238-239,329-331,344-345; CodexAppServer.swift:368-382; CodexThreadOperations.swift:391-399; AppServerProcessTransport.swift:969-974; upstream README:140-162,455-477, thread_state.rs:542-564, thread_processor.rs:2607-2619, thread_lifecycle.rs:55-60,351-354,406-436, lib.rs:718-719,996-1016,1156-1166. +- 修正の方向: Keep generation completion semantics. Add a typed `unloaded/generationEnded` reason only if consumers need to distinguish it from connection termination; never turn it into turn success/failure. Persisted resume starts a fresh generation, while ephemeral unload is not resumable. + +### `conf-command-delta-clobbers-item` — command output and current fileChange patch updates replace the transcript item with partial content + +**CONFIRMED / medium / bug** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:887` + +For current `item/commandExecution/outputDelta`, `itemUpdate` builds content with an empty command and only the latest delta; `CodexTranscriptAccumulator.upsert` replaces the `item/started` snapshot that carried command/cwd and prior output. Current `item/fileChange/patchUpdated` is emitted from `EventMsg::PatchApplyUpdated` only when the under-development `apply_patch_streaming_events` feature is enabled (default false); when reached, the same helper constructs a partial item and replaces existing metadata rather than applying the structured snapshot. Deprecated `item/fileChange/outputDelta` is compatibility-only. The medium severity is supported by the normally reachable command path alone. + +- 証拠: CodexAppServerNotificationRouter.swift:773-780,887-905; CodexTurnSequences.swift:667-668; upstream event_mapping.rs:417-423 and README:1418 patchUpdated, apply_patch.rs:85-95 and features/src/lib.rs:949-953 feature gate/default, README:1411-1414 command delta, :1416-1419 legacy file delta; compensator CodexModel.swift:1193-1197. +- 失敗トレース: (1) Server: item/started with commandExecution item {id: "item_1", command: "cargo test", cwd: "/repo"} -> accumulator stores full item (router :753-757; CodexTurnSequences.swift:670-672); (2) server streams item/commandExecution/outputDelta {itemId: "item_1", delta: "chunk A"} -> router itemUpdate builds CodexThreadItem(id: "item_1", content: .command(command: "", output: "chunk A")) (router :887-905); (3) upsert finds index for "item_1" and executes items[index] = item (CodexTurnSequences.swift:667-668) — command text and cwd vanish from the transcript, output shows only "chunk A"; (4) next delta {delta: "chunk B"} replaces again -> output shows only "chunk B", never "chunk Achunk B"; (5) any CodexThreadTranscriptSequence / CodexReviewProgressSequence / CodexResponseStream snapshot consumer renders the corrupted item for the entire command runtime until item/completed's aggregatedOutput restores it. CodexDataKit consumers are shielded only by the per-layer compensator (CodexModel.swift:1193-1197). +- 修正の方向: Transcript accumulator owns method-specific update semantics: append command output; apply fileChange patch snapshots while preserving item identity/metadata; keep legacy text delta separate. DataKit consumes the same update operation instead of re-implementing it. + +### `conf-error-payload-discarded` — Structured JSON-RPC error data is not preserved across the public request boundary + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:1140` + +Upstream `TurnError` includes `codexErrorInfo` and `additionalDetails`, and several request failures serialize structured data that distinguishes context-window, usage-limit, steerability, and related categories. CodexKit decodes only message in its request DTO and the transport does not preserve JSON-RPC `error.data`, so those categories cannot reach a public typed error. The interrupt retry/redirect is a separate upstream gap: current stale/no-active interrupt responses are plain `invalid_request(message)` with no structured data, so preserving data alone will not remove that string parse. Error notifications remain raw-readable via `.unknown` but are not typed. + +- 証拠: AppServerRequests.swift:1140-1147 message-only; AppServerProcessTransport.swift:251-258 data not preserved; upstream thread_data.rs:266-275 `TurnError`, shared.rs:92-133 `CodexErrorInfo`, `app-server/src/request_processors/turn_processor.rs:921-951` error.data serialization. +- 修正の方向: Preserve `error.data` in the transport and decode known `TurnError/CodexErrorInfo` values with an unknown/raw catch-all at the public error boundary. Separately request structured interrupt error data or a typed cancel affordance upstream. + +### `conf-ratelimits-replace-not-merge` — account/rateLimits/updated is replace-applied instead of merged into the last snapshot + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:1166` + +Upstream documents the notification as a sparse rolling update ('merge available values into the most recent read response... does not clear a previously observed value'). CodexAppServer.accountEvent builds a brand-new CodexRateLimits from only the notification, so an update carrying only primary clears secondary and planType for consumers treating the event as current state; no merge owner is exposed. The read response also silently drops rateLimitResetCredits, credits, limitName, individualLimit. + +- 証拠: CodexAppServer.swift:1166-1182; AppServerRequests.swift:1839-1884; upstream account.rs:508-515 sparse merge, :294 `rateLimitResetCredits`, :520-529 `limitName` / `credits` / `individualLimit` / `rateLimitReachedType`. +- 失敗トレース: (1) Client reads full snapshot: primary+secondary windows and planType populated; (2) server emits a legal sparse update with only primary (account.rs:508-515; optional fields :520-529); (3) CodexAppServer constructs a brand-new CodexRateLimits from that payload; (4) consumer now observes secondary/planType nil although absence must not clear prior values. +- 修正の方向: CodexAppServer holds the last snapshot and emits merged state (or explicitly types the event as a partial delta) so 'absent field' can never read as 'cleared value'. + +### `conf-server-request-resolved-ignored` — serverRequest/resolved is never handled, so aborted server requests leave client handlers hanging + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:824` + +Upstream aborts pending server→client requests on turn start/complete/interrupt and announces it via serverRequest/resolved {threadId, requestId}. CodexKit routes it to .unknown and nothing correlates it with in-flight serverRequestHandler invocations: a custom handler awaiting user input waits forever (its Task in AppServerProcessTransport.respond is never cancelled) and its late answer is silently ignored server-side. Invisible with the default auto-decline handler, inherited by any consumer implementing interactive approvals. Cross-ref cov-server-requests-untyped. + +- 証拠: Router default `.unknown` at CodexAppServerNotificationRouter.swift:824-826; handler tasks AppServerProcessTransport.swift:234-245,289-313; upstream v2/notification.rs:52-57; abort sites bespoke_event_handling.rs:154,184,1047-1049. +- 修正の方向: Transport (owner of server-request lifecycle) tracks in-flight server requests by id, observes serverRequest/resolved, and cancels the corresponding handler task / exposes cancellation to the handler API. + +### `cov-dead-compat-notifications` — Router keeps current-dead and historical compatibility notification routes without an explicit policy + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:661` + +The pinned v2 baseline emits none of `turn/failed`, `turn/cancelled`, `item/updated`, or `agent/message`; real failures arrive through `turn/completed(status=failed)`. History is nuanced: `turn/failed` and `item/updated` briefly existed in the protocol schema around upstream `a010c1b7fcce` and were removed in `ce35cb16b279`, with no implementation emission found; the other two exact methods were not found. The router nevertheless exposes `.turnFailed` branches and current tests/previews emit these methods. Fallback IDs are minted both by `.message` compatibility events and by agent-message deltas missing itemID; the former performs fallback promotion. Current valid v2 requires delta itemID, so both inputs are compatibility behavior rather than normal-wire identity. + +- 証拠: Router :485,549,574-575,661-662,668,698-706,748-751,758-762,788-796; current protocol common.rs:1613-1710; historical commits `a010c1b7fcce` / `ce35cb16b279`; optional delta decode router :1084-1096; fallback append/promotion CodexTurnSequences.swift:647-693; upstream item.rs:1333-1338; fixtures CodexAppServerKitTests.swift:3541,3575,3662 and CodexDataKitTests.swift:8091. +- 失敗トレース: (1) Real failure emits `turn/completed(status=failed)` (upstream common.rs:1631; turn.rs:29-35). (2) Router decodes `.turnCompleted`, never `.turnFailed`, whose branch requires current-dead methods. (3) Consumer must derive failure from response status/error. SDK tests/previews can still pass against their compatibility dialect. +- 修正の方向: Publish a compatibility window. Default router/testing semantics follow current v2; historical methods, if retained, live in an explicit legacy namespace with removal tests. Do not synthesize a second terminal wire event. Make current-v2 delta itemID required and migrate fixtures to `turn/completed` and current item-specific deltas. + +### `cov-error-warning-untyped` — error/warning/deprecationNotice/configWarning are specially routed but never typed; turn/completed decode failure degrades to synthetic success + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:556` + +The router specially routes these four methods but has no typed cases, so they surface as `.unknown(CodexRawNotification)`. They are therefore available to a raw-aware consumer, not globally invisible; typed progress and CRK's adapter drop them. The unscoped broadcast branch is unreachable for `error` because it requires threadID/turnID, but it is load-bearing for `warning` (optional threadID), `deprecationNotice` (no threadID), and config warnings. Related leniency remains: `turnResult()` uses `try?`; an undecodable `turn/completed` becomes an empty/nil-status response that the collector can treat as success. + +- 証拠: Router :274-286,556-563,657-731,743-827,1011-1026; upstream notification.rs:11-16,21-26,41-48; consumer AppServerCodexReviewBackend.swift:585-586,645-650. +- 修正の方向: Add typed .errorReported(message:willRetry:) and warning-family events owned by the router (routing infra already exists), and make turn/completed decode failure loud instead of synthesizing empty success. + +### `cov-server-requests-untyped` — Server-initiated requests have no typed surface and the default handler answers protocol-invalid {} for requestUserInput/permissions + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:128` + +`defaultServerRequestHandler` declines commandExecution/fileChange approvals with known shapes; every other request gets `{}`. Upstream requires method-specific fields for requestUserInput and permissions, so `{}` fails serde and is logged before the server substitutes a deny/empty result. Dynamic tool calls and MCP elicitation also require method-specific lifecycle decisions. Unknown methods should receive a JSON-RPC error, not a fabricated success object. No typed params/decision enums exist for this public handler surface. + +- 証拠: CodexAppServer.swift:128-138 default handler {} fallback, :98-102 doc claim; CodexAppServerRequest.swift:4-34 raw-only shape; upstream item.rs:1644-1646 (answers required), permissions.rs:769-777 (permissions required), bespoke_event_handling.rs:1618-1623,1816-1824 malformed-response handling; request list common.rs:1462-1493. +- 失敗トレース: (1) Server sends item/tool/requestUserInput (common.rs:1475-1478) to a CodexKit host using the default configuration; (2) defaultServerRequestHandler hits the default: branch and returns .emptyResult() = {} (CodexAppServer.swift:136-137); (3) transport writes {"id":n,"result":{}} (AppServerProcessTransport.swift:289-296, :315-328); (4) server deserializes ToolRequestUserInputResponse from {} -> serde error because answers is required (item.rs:1644-1646) -> error!("failed to deserialize ToolRequestUserInputResponse") and substitutes answers: {} (bespoke_event_handling.rs:1617-1623). Every such exchange is a logged contract violation degrading to an empty/deny answer; same for item/permissions/requestApproval via permissions.rs:769-777 and bespoke_event_handling.rs:1817-1824. +- 修正の方向: The default handler owns 'safe decline' for all interactive requests: enumerate known methods with valid decline-shaped responses and answer unknown methods with a JSON-RPC error; add typed request cases + decision enums, keeping raw only as escape hatch. + +### `dk-closed-fabricates-completion` — DataKit can synthesize `.completed` from unload/status events in stale-state paths + +**CONFIRMED / low / protocol-mismatch** — `CodexKit:Sources/CodexDataKit/CodexModel.swift:1258` + +`thread/closed` and thread status do not declare a turn outcome, yet `CodexChat.apply(.closed)` and `terminalTurnStatus` for `.idle/.notLoaded` can mark locally non-terminal turns/items `.completed` with synthesized timestamps. Upstream never unloads a running thread, and `.closed` is not normally reachable in the pinned CodexKit. The branch matters only if the local model is stale/non-terminal because a terminal event was missed or reordered; it is a latent fabrication path, not a mid-turn server trace. + +- 証拠: CodexModel.swift:1258-1264,1669-1701,1703-1733; upstream thread.rs:1467-1469 and thread_lifecycle.rs:351-354. +- 修正の方向: Event-application layer leaves non-terminal statuses untouched on unload (or introduces explicit .unknown/.interruptedByUnload) and lets the next authoritative snapshot decide; terminal statuses only from server-declared terminal events. + +### `dk-implicit-archived-scope` — Predicates that don't mention isArchived silently get archived == false injected + +**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexDataKit/CodexThreadQueryPlan.swift:308` + +CodexThreadServerFilter.init injects archived=false whenever the lowered predicate's archive scope is unscoped (both derived-filter and no-filter paths), and plan.matches() applies the injected scope locally too — so a #Predicate matching archived chats silently excludes them, invisible at compile time and undocumented. SwiftData evaluates predicates literally; the convenience descriptors already spell isArchived == false explicitly, making the injection a second hidden source of the same semantics. + +- 証拠: CodexThreadQueryPlan.swift:300-315 unscoped → archived=false, :293-296 fallback defaultChatFilter, :124-126 matches applies it; contrast explicit CodexFetchRequest.swift:381-385. +- 修正の方向: Evaluate predicates literally (unscoped ⇒ both scopes) keeping default-scoping only for the nil-predicate default, or make the implicit scope an explicit documented descriptor property. + +### `dk-mutation-strategy-scattering` — No single owner for the local-apply vs server-refresh decision in fetched-results mutations — the recurring-fix magnet + +**CONFIRMED / medium / architecture** — `CodexKit:Sources/CodexDataKit/CodexFetchRequest.swift:818` + +Each registration handler re-derives “apply locally or refetch” with a different guard set: insert checks only membershipRequiresServerRefresh; archive/revalidate add usesServerOwnedOrdering; remove special-cases fetchOffset; workspace refresh filters before deciding; paged revalidation and offset/limit constraints live elsewhere. Concrete asymmetry: insert skips the server-owned-ordering check its siblings enforce. Recent commits patched the same responsibility neighborhood—predicate lowering/local semantics (`c58c47b`, `33cd29e`, `f350209`) and sort signatures (`b141853`)—rather than these exact guard lines, which still signals that mutation strategy lacks one owner. + +- 証拠: CodexFetchRequest.swift:818-827,829-851,887-957,1037-1042,1066-1076,1116-1132; CodexModelContext.swift:2442-2446; commit diffs `c58c47b`, `33cd29e`, `f350209`, `b141853`. +- 修正の方向: CodexThreadQueryPlan exposes a single mutationStrategy(for:) covering membership, ordering, offset and pagination; handlers become thin executors — one place decides when local state can be trusted. + +### `dk-optional-delta-id-workaround-layer` — Fallback agent-message identity machinery in DataKit compensates for AppServerKit's optional delta itemID (upstream requires item_id) + +**CONFIRMED / medium / workaround** — `CodexKit:Sources/CodexDataKit/CodexModel.swift:1926` + +Upstream `AgentMessageDeltaNotification.item_id` is required but `CodexMessageDelta.itemID` is optional, so DataKit maintains scoped fallback IDs, promotion maps, and text-signature matching across several merge paths. Nil-ID fallback can be driven both by compatibility `.message` events and by malformed/legacy agent-message deltas whose itemID is absent; the latter is not valid current v2 wire. Text equality can alias identical messages in one turn, and each merge path must remember the promotion rule. + +- 証拠: upstream item.rs:1333-1338 item_id: String required; CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2222-2231 itemID: String?; CodexModel.swift:53-59,967-1012,1427-1447,1926-2005,2280-2303 compensation sites. +- 修正の方向: AppServerKit decode boundary makes delta item IDs non-optional per the wire contract (fail fast on absent id), then delete the fallback-ID/text-signature layer from DataKit — item identity is assigned once, at the protocol boundary. + +### `dk-predicate-runtime-crash-contract` — Unsupported predicate/sort shapes crash at runtime with preconditionFailure, including inside SwiftUI view update + +**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexDataKit/CodexThreadQueryPlan.swift:831` + +The `#Predicate` / `SortDescriptor` surface accepts any model key path at compile time, but lowering supports only 5 predicate keys and 5 sort paths; unsupported shapes hit `preconditionFailure` in descriptor construction, section descriptors, validation, or lowering. `CodexQuery.update()` computes the signature on SwiftUI update, so a dynamic unsupported descriptor can crash during render. By comparison, SwiftData's explicit `ModelContext.fetch` surface is throwing and its error model includes unsupported predicate/sort failures; this audit does not assert the exact behavior of SwiftData's `@Query.update`. CodexDataKit has no throwing validation surface and does not document its supported grammar. + +- 証拠: CodexThreadQueryPlan.swift:831-838,953,973,984,995,1006,1046,1205,1220 preconditions; CodexFetchRequest.swift:43-54,111-118,193-198; CodexQuery.swift:142-152 signature per update(); DataKit README.md:74-79. +- 修正の方向: Give the contract an owner: document the supported grammar, and expose a throwing validate(descriptor:)/typed filter-sort enums so dynamic construction gets an error path instead of a render-time crash. + +### `dk-query-not-live-external` — @CodexQuery/fetchedResults do not automatically ingest external server-side thread-list changes + +**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexDataKit/CodexModelContext.swift:2147` + +Fetched-results updates fire only from context-initiated actions; CodexModelContext subscribes to no app-server-level notifications (thread/started, thread/name/updated, thread/archived, thread/deleted exist upstream), so threads created/renamed/archived/deleted by another client, the codex CLI, or even a dropped-down AppServerKit call on the same container never appear/disappear until an explicit performFetch/refresh. The README's SwiftData framing, the 'fetched results controller' naming, and the documented appServer drop-down hatch all imply live membership that is not provided, and nothing documents the staleness contract. + +- 証拠: CodexModelContext.swift:2147-2262 registration fan-out from context-initiated paths only; grep notificationStream/liveEvents over CodexDataKit = 0; upstream common.rs:1616-1624 lifecycle notifications; DataKit README.md:171-177 documents appServer hatch without staleness caveat. +- 修正の方向: Context owns a server-notification ingestion path (thread lifecycle → registry apply + fan-out) making fetched results genuinely live; short of that, README/API must state that query results only track context-local mutations. + +### `dk-single-observation-slot` — One-observation-per-chat contract makes rebinding racy; slot is held by in-flight registrations with no awaitable release + +**CONFIRMED / medium / usability** — `CodexKit:Sources/CodexDataKit/CodexModelContext.swift:804` + +`observe()` throws `chatObservationAlreadyActive` if a registration exists, and registration is inserted before asynchronous `startObservation` completes. Cancellation of that in-flight registration is Task cancellation with no awaitable “slot released” completion, so rapid rebind can race. Once a `CodexChatObservation` handle is fully established, its `cancel()` synchronously calls `releaseChatObservation`; that path is not racy. Updates are multicast, so the in-flight single-slot lifecycle still leaks into UI code (see `use-observe-serialization`). + +- 証拠: CodexModelContext.swift:804-827 registration; :972-981 established-handle release; consumer ReviewMonitorCodexChatLogTarget.swift:157-166. +- 修正の方向: CodexModelContext owns observation sharing: observe() joins (refcounts) the existing ActiveChatObservation returning a new handle, tearing the pump down when the last handle cancels — or make cancellation synchronously release the slot. + +### `dk-sortdescriptor-mirror-reflection` — Sort signature depends on Mirror reflection into Foundation SortDescriptor internals with silent nil fallback + +**CONFIRMED / medium / workaround** — `CodexKit:Sources/CodexDataKit/CodexFetchRequest.swift:78` + +`comparisonSignature` extracts the private `comparison` child of `SortDescriptor` via `Mirror` + `String(describing:)`. Both label and description depend on undocumented layout. On CodexKit's macOS 15.4 floor, Foundation already exposes public `SortDescriptor.keyPath`, `stringComparator`, and `order`; `String.StandardComparator` is Equatable/Hashable/Codable. The current collision risk is therefore specifically standard-string-comparator distinctions that the implementation reads through reflection instead of the public property. + +- 証拠: CodexFetchRequest.swift:78-84; CodexThreadQueryPlan.swift:174-184; CodexKit Package.swift macOS 15.4; Xcode 26.6 / macOS 26.5 SDK `Foundation.swiftinterface` exposes `keyPath` and `stringComparator` from macOS 14 and `String.StandardComparator` conformance; commit `b141853`. +- 修正の方向: Build the signature and validation from public `keyPath`, `stringComparator`, and `order`; delete Mirror. Reject only genuinely unsupported descriptor forms through the documented validation surface. + +### `dk-unserialized-fetch-loads` — Concurrent load() calls on one CodexFetchedResults interleave; pagination window collapses on mutation-triggered reloads + +**CONFIRMED / medium / bug** — `CodexKit:Sources/CodexDataKit/CodexFetchRequest.swift:683` + +`performFetch` / refresh / loadNextPage / mutation refresh / backfill funnel into `load()` with suspension points and no serialization or generation token. A mutation-triggered refresh can interleave with a suspended page append, producing items and cursor from different generations. Paged revalidation also reloads page 1 non-appending and discards the currently loaded window; whether to preserve or reset that window is an undocumented query contract, not something inferred from another framework. + +- 証拠: CodexFetchRequest.swift:661-681 no guards, :683-722 load() assigns cursors/items after suspension (:707), :1116-1132 refreshAfterPagedRevalidationIfNeeded non-appending reload. +- 失敗トレース: Interleave: 1) Paged results (fetchLimit=50, recencyAt reverse), page 1 loaded, nextCursor=C1 (:707). 2) User scrolls -> loadNextPage() (:671-681) -> load(cursor:C1, appending:true), phase=.loading (:689), suspends at fetchPage (:693). 3) MainActor reentrancy: user archives a chat -> CodexModelContext.archiveChatInRegisteredResults (CodexModelContext.swift:2154-2163) -> CodexFetchedResults.archive (:829-851); requiresServerRefreshAfterMutation is true for recencyAt ordering (:1066-1068; CodexThreadQueryPlan.swift:116-118) -> refreshAfterMutation (:1108-1114) -> load(appending:false) completes: items = fresh page 1, nextCursor = C1' (:707-715). 4) Step-2 load resumes with the stale-C1 response: append(page.items, to: items) (:698, :750, :761-771) welds stale page-2 rows onto the fresh page-1 list, then overwrites nextCursor with the stale response's C2 (:707). Result: items mix two membership generations and nextCursor belongs to the losing load (A->B->A-completed). Collapse: user on page 3 (nextCursor != nil) + any included-chat revalidation -> refreshAfterPagedRevalidationIfNeeded (:1116-1132) -> non-appending page-1 reload -> scrolled pages discarded. +- 修正の方向: CodexFetchedResults owns load serialization (in-flight task/generation counter; coalesce refreshes, queue-or-cancel appends) so items/cursors/phase always describe one completed load; page-window preservation belongs to the same owner. + +### `dx-cancel-semantics-inconsistent` — Two different cancellation semantics on CodexResponseStream: task-cancel during collect() interrupts the server turn (unstructured, failure-swallowed), early break does not + +**CONFIRMED / medium / usability** — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2002` + +Breaking out of iteration (or dropping the sequence) only unsubscribes, so server work continues. Cancelling the Task awaiting `collect()` starts an ownerless unstructured `Task { try? await turn.interrupt() }`; it is not `Task.detached`, but no lifecycle owner awaits or observes it. The interrupt failure is swallowed and the Task can race server close. Since `respond(to:)` is `streamResponse().collect()`, aggregate use inherits interrupt-on-cancel while sequence iteration does not. + +- 証拠: CodexDomainTypes.swift:1994-2008 unstructured interrupt; CodexThreadOperations.swift:23-25,74; router :105-107,138-140; README.md:102-103. +- 修正の方向: Choose one documented contract: caller cancellation either stops listening only, or also requests server interrupt. The synchronous `onCancel` handler can only signal a stored cancellation authority; the operation path must await the interrupt Task/completion and arbitrate its failure before returning. Do not attempt to await or throw from `onCancel` itself. + +### `test-fictional-turn-failed-pins-phase-contract` — The only test pinning chat.phase == .failed drives it via a 'turn/failed' notification that does not exist upstream; the production-reachable failure path is unpinned + +**CONFIRMED / medium / test-gap** — `CodexKit:Tests/CodexDataKitTests/CodexDataKitTests.swift:8091` + +The test emits method turn/failed with a top-level error object — a wire event no real server sends — exercising the production-unreachable .turnFailed branch, while the reachable path (turn/completed + status failed + turn.error.message → fail(with:)) has no direct phase assertion (the revert-policy test asserts only rollback/read counts). If the fictional routes are removed to match upstream (cross-ref cov-dead-compat-notifications), this test breaks and reveals no pin exists for the real shape. + +- 証拠: CodexDataKitTests.swift:8090-8101 emits `turn/failed`; upstream server notification enum common.rs:1613-1710 and concrete `TurnCompleted` at :1631; turn.rs:30-35; reachable path CodexModel.swift:1142-1151; revert test without phase assertion CodexDataKitTests.swift:6267-6281. +- 修正の方向: Pin the failure-phase contract via the upstream-real shape (turn/completed, status failed, turn.error.message) and remove the fictional emission together with the router's dead routes. + +### `test-handrolled-notification-schemas-drift` — Notification wire schemas are hand-rolled in at least three places with visible drift; package-scoped AppServerAPI blocks consumers from typed payloads + +**CONFIRMED / medium / workaround** — `CodexKit:Tests/CodexDataKitTests/CodexDataKitTests.swift:10258` + +Because the Testing target offers only emitServerNotification(method:params:) and raw JSON, every fixture author re-encodes the wire schema: ~15 private param structs in CodexDataKitTests, 6 more in the consumer's preview runtime, and a third private encoder in the Testing target itself; AppServerAPI/AppServerJSONValue are package-scoped so consumers can't reuse the SDK's wire types. Drift is live: the preview runtime emits turn.status "cancelled" (absent from upstream TurnStatus, round-trips only via the invented CodexTurnStatus.cancelled) and a DataKit test emits nonexistent turn/failed. Nothing validates fixtures against the protocol — fixtures define their own dialect and tests pin it. + +- 証拠: CodexDataKitTests.swift:10204-10430; ReviewMonitorPreviewAppServerRuntime.swift:717-727,733-815; Testing encoder CodexAppServerTestRuntime.swift:767-842; package scope AppServerRequests.swift:3,162; upstream turn.rs:30-35. +- 修正の方向: Testing target owns method-specific builders for current wire methods (`emitTurnCompleted`, `emitCommandExecutionOutputDelta`, `emitFileChangePatchUpdated`, `emitThreadStatusChanged`, server requests) using the same package DTOs as the router. Do not institutionalize dead `emitItemUpdated` / `emitTurnFailed`; legacy file output delta belongs to explicit compatibility tests. + +### `test-no-server-request-injection` — Testing target cannot inject server-initiated requests, so approval flows (public API) are untestable against the fake + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:402` + +`CodexAppServerRequestHandler` is public, but `JSONRPC.Transport` does not model server requests, the in-memory fake has no `CodexAppServerRequest`, and `CodexAppServer.testing(transport:)` accepts no handler. A shell-backed process test covers the real transport, so the capability is not wholly untestable; it is not deterministically testable through the in-memory fake. CRK currently has no production approval-handler consumer, making this a public-surface coverage gap rather than an observed CRK workaround. + +- 証拠: CodexAppServerRequest.swift:74 public typealias; real dispatch AppServerProcessTransport.swift:234-245,289-313; JSONRPC.swift:26-31 protocol without server requests; CodexAppServer.swift:210-214 testing(); only test CodexAppServerKitTests.swift:100-161. +- 修正の方向: Add server-request delivery to JSONRPC.Transport (or a companion protocol) so the fake exposes emitServerRequest(...) returning the handler's response. + +### `test-process-crash-recovery-untested` — Process crash/EOF recovery and in-flight-cancel response drop are untested anywhere in the SDK + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/AppServerProcessTransport.swift:208` + +Untested: stdout EOF → closeTransport → pending requests resumed .closed and streams finished throwing; cancelPendingResponse dropping late responses; SIGTERM→SIGKILL escalation and process-tree reaping; malformed stdout JSON silently dropped. Downstream, no SDK test covers DataKit observations/fetched results when the router's stream throws .closed — finishNotificationStreams(throwing:) exists in the Testing target but is used only by the consumer repo. Crash recovery is a recurring-fix hot spot with no pins. + +- 証拠: AppServerProcessTransport.swift:208-217,354-356,362-373,700-717,219-232; grep finishNotificationStreams over CodexKit/Tests = nothing (consumer-only usage CodexReviewHostTests.swift:1610,1686); process tests limited to CodexAppServerKitTests.swift:100-168. +- 修正の方向: Pin the transport's terminal contract with a shell-script process exiting mid-request (pending send fails .closed, streams finish throwing), and pin DataKit observation behavior on stream error via finishNotificationStreams(throwing:). + +### `test-unstubbed-method-silent-success` — Unstubbed requests silently succeed with '{}', fabricating success for every EmptyResponse-typed mutation + +**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1109` + +send falls through to encoding EmptyResponse() when no queued response or handler matches. thread/archive, thread/delete, thread/name/set, turn/interrupt, config/batchWrite, logout, etc. 'succeed' with no stub while the store is unchanged; a typo'd enqueue method is silently absorbed; required-field responses instead surface a DecodingError that reads like an SDK bug rather than 'missing stub'. A fake should fail fast on unintentional requests. + +- 証拠: CodexAppServerTestRuntime.swift:1106-1109 fall-through; Thread.Start.Response non-optional threadID (AppServerRequests.swift); store registers only start/list/resume/read/turns-list (:569-585). +- 修正の方向: Throw a distinct unstubbedMethod(method:) error on fall-through, with an opt-in lenient mode for previews — silent divergence becomes immediate test feedback. + +### `test-fake-cancel-in-flight-returns-success` — In-memory fake returns success when an equivalent real in-flight request throws CancellationError + +**CONFIRMED / low / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1094` + +When a Task awaiting fake `send()` is cancelled at a test gate, `cancelWaiter` resumes normally and the queued response is returned as success. The real process transport resumes the pending continuation with `CancellationError` and discards a late response. SDK-internal request sites that use cancellation shielding limit current impact, but a transport contract test written against the fake observes the opposite outcome. + +- 証拠: CodexAppServerTestRuntime.swift:1080-1109 fake gate/cancel path; AppServerProcessTransport.swift:113-130,354-356 real pending-response cancellation/drop; shielded SDK request sites use `Task.detached`. +- 修正の方向: Make fake cancellation resume the waiter with `CancellationError` and discard its queued/late response. Pin the same contract against both in-memory and process transports. + +### `use-dual-thread-identity` — Source-vs-review thread identity is re-mapped independently at three consumer layers + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:22` + +CodexKit already defines the canonical pair `CodexReviewIdentity.sourceThreadID` and `activeTurnThreadID`, plus associated/cleanup IDs. The gap is in CRK: its Run/event plane type-erases that identity into optional strings. The backend then keeps routing dictionaries, the store compares both raw IDs, and MCP chooses `reviewThreadID ?? threadID`. Three consumer layers reconstruct a mapping the SDK type already owns. + +- 証拠: AppServerCodexReviewBackend.swift:22-28,360-410; CodexReviewStoreOrderQueries.swift:94-108; CodexReviewMCPServer.swift:174; SDK `CodexReviewIdentity` CodexDomainTypes.swift:621-676. +- 修正の方向: Define a CRK domain identity preserving the source/active-turn pair and map `CodexReviewIdentity` into it once at the adapter boundary. Carry that value through Run/event contracts without importing CodexAppServerKit into the core. + +### `use-full-reprojection` — Granular CodexChatUpdate payloads are discarded; every delta triggers full chat re-projection + +**CONFIRMED / medium / usability** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift:54` + +Typed granular updates are used only as a boolean “allow incremental render” hint; the consumer re-renders the whole `chat.items` array through the projection pipeline on every update and re-derives diffs via UTF-16 text comparison. The per-delta O(items) work is confirmed. Current evidence does not establish whether this is forced by an SDK identity/ordering defect or is simply the consumer's conservative implementation; the related update-ID hypothesis remains unverified in Appendix B. + +- 証拠: ReviewMonitorCodexChatLogSourceProjection.swift:54-101; downstream diffing ReviewMonitorLogProjection.swift:5-100. +- 修正の方向: First identify which current update cases have a sufficient typed identity/order contract, then apply those targetedly in CRK. Escalate to an SDK contract change only for cases where that proof fails. + +### `use-highlevel-surface-bypass` — Consumer uses typed events but bypasses terminal aggregate convenience surfaces + +**CONFIRMED / medium / api-design** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:752` + +`CodexReviewSession` offers parallel public consumption styles: typed `events`, `progress`, `collect()`, messages, and log entries. CRK uses typed `session.events` and `cancel()`, not raw JSON-RPC, while implementing its own terminal mapping and output selection instead of using the aggregate surfaces. The confirmed reason is interrupted-vs-failed classification and CRK-specific backend/product policy; `.closed` is only a latent contributor. This is evidence that terminal aggregation needs a clearer contract, not that every high-level review API is unusable. + +- 証拠: AppServerCodexReviewBackend.swift:723-763 typed session events/cancel path, :590-614 terminal/output mapping; CodexDomainTypes.swift:893-900; CodexTurnSequences.swift:303-309,482-484. +- 修正の方向: Define aggregate outcome semantics and review-output contract, then reevaluate which adapter logic becomes a thin mapping. Keep CRK product cancellation arbitration at its current owner. + +### `use-mcp-refresh-fallback` — MCP refresh failure and projection absence collapse to the same unavailable result + +**CONFIRMED / medium / usability** — `CodexReviewKit:Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift:167` + +The MCP projection intentionally retains no live observation token, so architecture docs make on-demand refresh the snapshot owner. Refreshing on each read is therefore not itself a workaround or owner violation. The defect is error collapse: projection absence and refresh failure both become nil and then `.unavailable`, so a caller cannot distinguish “no log yet” from “snapshot refresh failed.” + +- 証拠: CodexReviewMCPServer.swift:153,178-187; ReviewMCPLogProjection.swift:38-51; `Docs/architecture.md:177-179` on-demand snapshot contract. +- 修正の方向: Keep on-demand refresh, but return a typed refresh failure separately from the intentional `.unavailable` projection. A live observation is optional only if the architecture changes its ownership contract. + +### `use-observe-serialization` — Consumer serializes chat rebinds by awaiting previous task teardown to dodge chatObservationAlreadyActive + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift:159` + +Because an in-flight `observe()` registration holds the one-per-chat slot until its Task unwinds, the consumer retains the previous Task, cancels it, and `await`s `previousTask.value` before re-observing the same chat. A fully established observation handle releases synchronously; this workaround targets only the in-flight registration window. + +- 証拠: ReviewMonitorCodexChatLogTarget.swift:159-163 comment + await, :91-93 kept reference; SDK CodexModelContext.swift:804-809, CodexChatObservation.swift:22-32. +- 修正の方向: CodexDataKit owns observation lifecycle: broadcast (multi-observer) updates or an awaitable/idempotent cancel-and-reobserve, removing consumer-side task sequencing. + +### `use-resume-to-cancel` — A registry-miss fallback resumes a review session solely to cancel, but production reachability is unproven + +**PLAUSIBLE / low / usability** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:466` + +CodexKit exposes cancel only on live handles and package-scopes direct turn interrupt. The fallback branch in CRK calls `appServer.resumeReview(identity)` solely to obtain `.cancel()`, so the architectural pressure is visible. A production trigger was not established: active runs register a session, restart-wait cancellation can complete locally, and normal cleanup follows terminal completion. The original “post-restart/cleanup” failure story is therefore unsupported. + +- 証拠: AppServerCodexReviewBackend.swift:466-470 resume-then-cancel, :349-358 manufactured session; SDK CodexThreadOperations.swift:448-466 package interrupt() only. +- 修正の方向: First add a targeted test or runtime trace that reaches the registry-miss branch. If restoration/cancellation is a real consumer story, expose cancel-by-identity and remove resume-to-cancel; otherwise delete the unreachable fallback instead of expanding SDK API. + +### `use-review-output-location` — Consumer duplicates the existing `reviewOutputText` contract with fallback and failure synthesis + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:610` + +CodexKit already documents `CodexTranscript.reviewOutputText` as review output derived from `exitedReviewMode`, and `CodexResponseAccumulator.finalized` fills the terminal response accordingly. CRK nevertheless probes `reviewOutputText ?? finalAnswer ?? transcript.finalAnswer`, synthesizes an empty-output failure in backend and store, and MCP adds `finalReview ?? lastAssistantMessageText`. The churn is confirmed consumer-side redundancy, not a missing SDK field contract. + +- 証拠: CodexAppServerKit README:293-299; CodexDomainTypes.swift:1364-1370; response finalization path; CRK AppServerCodexReviewBackend.swift:602-614; CodexReviewStoreReviews.swift:688,709,856-863; ReviewMCPLogProjection.swift:91-94. +- 修正の方向: At the adapter boundary, treat missing/empty `reviewOutputText` as one typed completion failure and make successful `Completion.finalReview` non-optional. Delete downstream duplicate checks/fallbacks. Keep “stream ended without terminal” as a separate transport/backend failure. + +### `use-runtime-death-side-channel` — App-server death is detected via accountEvents stream error, tearing down the whole runtime from a side channel + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:1186` + +CodexAppServer has no explicit lifecycle/termination surface, so the host uses failure of a typed `accountEvents()` notification stream as the runtime-death signal and tears down from that catch. Notification streams are multicast, so another consumer does not steal the signal; graceful shutdown also cancels the auth notification Task before close. The narrow gap is semantic: an account-domain stream failure doubles as connection termination rather than exposing termination directly. + +- 証拠: LiveCodexReviewStoreBackend.swift:1184-1188 catch → markRuntimeFailedAfterNotificationStreamError, teardown :1200-1229; SDK public surface (CodexAppServer.swift:220-867) has close() but no state/termination signal. +- 修正の方向: Expose one connection lifecycle/termination completion shared by all retaining handles. Keep notification streams domain-scoped and define whether graceful close finishes or throws independently. + +### `use-review-marker-duplication` — Consumer duplicates the SDK-private 'review-marker:' semantic-ID constants for snapshot items — incompletely + +**CONFIRMED / low / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:785` + +CodexDataKit's private semanticID maps entered/exited review markers to `review-marker:` constants and prefixes other kinds. Snapshot items bypass the model, so CRK re-implements the marker constants without the general kind-prefix branch. No production call site feeds `CodexThreadSnapshotLogItem`; the divergence is currently limited to test/preview snapshot rendering, but two unversioned copies remain a maintenance hazard. + +- 証拠: ReviewMonitorCodexChatLogProjection.swift:785-794; SDK private twin CodexModel.swift:512-526 incl. kind-prefix branch. +- 修正の方向: Delete the unused snapshot-render overload and duplicated mapping first. Expose a public normalization/mapping API only if a production snapshot consumer is introduced. + + +## Appendix B: 未検証 low findings(35 件) + +- `arch-dead-adapter-branches` [workaround] Dead workaround residue in the review event adapter/session: write-only cancel state, no-op branches, never-incremented metrics, ignored expectedTurnID — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:635` + - Vestiges of the cancel/terminal workarounds: itemEvents guards `item.kind.rawValue != "enteredReviewMode"` yet both branches return []; unknownStatusEvents/unknownEvents unconditionally return []; activeStreamSubscriptionIDForTesting returns nil and detach(subscriptionID:) is a no-op; cancelReview(expectedTurnID:) ignores its parameter so the caller's expected-turn guard (:459-464) is unenforced; ReviewBackendEventSession.cancellationRequestedMessage is written by requestCancellation/clearCancellationRequest/finish but never read (making the backend's round-trip a no-op ritual); metrics buffered/commandTimeoutWarnings are never incremented yet logged as meaningful. Dead branches conceal where real semantics were meant to live. +- `arch-settings-snapshot-mirrors` [architecture] Settings snapshot kept in three places: SettingsStore (owner), SettingsService bookkeeping, and a per-backend mirror used only for seeding — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:228` + - Each backend caches its own settings snapshot updated on every read/write (Live, Direct, Preview backends), whose only consumer is initialSettingsSnapshot for store seeding; one store exists per backend, so the third copy is a mirror that can drift from SettingsStore between refreshes (failed refresh leaves the backend cache at the previous value). +- `arch-sidebar-order-ui-owned` [architecture] User drag-reorder of sidebar chats/groups lives only in an in-memory VC overlay; membership/order ownership split between FRC and view layer — `CodexReviewKit:Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift:140` + - Base membership/order comes from the fetched-results controller sorted by recencyAt; drag-and-drop mutates only ReviewMonitorCodexSidebarPresentationOrder (applied per render, pruned to live sections) and applyResolvedDrop never persists — user-intent ordering is semantic state in a UI wrapper that vanishes on VC recreation and silently re-merges under FRC updates. Session-scoped ordering, if intended, is undocumented; otherwise the order lacks a persistence owner. +- `arch-silent-persistence-writes` [workaround] Account registry and shared-auth writes on the success path are wrapped in try?, silently dropping persistence failures — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:1136` + - applyAuthSnapshot persists accounts and shared auth with try?; rate-limit cache writes likewise. If CODEX_HOME is unwritable, in-memory auth and the on-disk registry diverge with no signal — on next launch accounts vanish or an old account reactivates. Conflicts with the fail-fast posture: persistence failure of the account source of truth is not normal flow. +- `arch-store-preview-default` [api-design] CodexReviewStore.init defaults its backend to PreviewCodexReviewStoreBackend — a fake as the silent default of the core initializer — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStore.swift:28` + - Any composition path that forgets to pass a backend compiles and yields a store whose start() transitions to .failed("Embedded server is unavailable in preview mode.") at runtime instead of failing at the composition boundary. Explicit factories already exist; the default adds only a silent-misuse path (mitigated by package visibility). +- `arch-stringly-source-flag` [api-design] Rate-limit refresh uses a stringly-typed source parameter as a control-flow flag triggering account validation — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:1492` + - refreshRateLimits branches `if source == "saved-auth-isolated-runtime"` to run validateRateLimitBackendAccount; callers pass three magic strings. A typo or new caller silently skips the auth-identity validation guarding against refreshing rate limits for the wrong account — correctness keyed off a log label. +- `arch-transport-vc-duplicate-binding` [architecture] Chat binding state (boundChatID/boundModelContext) duplicated in TransportViewController and ChatLogTarget with parallel guards — `CodexReviewKit:Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift:14` + - Both layers keep their own copy of which chat is bound with identical guard expressions that must mutate in lockstep; the DEBUG helper bindLogRenderTargetForTesting already hand-synchronizes both. Drift produces a silently stale pane (outer layer thinks bound, inner cleared). +- `ask-blocking-pipe-writes` [bug] Synchronous FileHandle writes to the child's stdin run on the actor executor and can block a cooperative thread — `CodexKit:Sources/CodexAppServerKit/AppServerProcessTransport.swift:120` + - send()/notify()/respond(to:) call stdin.fileHandleForWriting.write(contentsOf:) inside the transport actor. If the codex process stops draining stdin and the 64KB pipe buffer fills, the blocking write wedges the cooperative-pool thread and the actor (all sends, close(), notification fan-out) indefinitely — a hung host app rather than an error. Needs a wedged child to manifest. +- `ask-clientversion-default-mismatch` [api-design] Two different clientVersion defaults: Configuration says "1", AppServerClient.initialize says "2" — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:114` + - Configuration.init defaults clientVersion to "1" (what every public init path sends) while AppServerClient.initialize's own default parameter is "2". Two sources of truth for the handshake value; which is current is ambiguous and the value silently differs by entry point. Cross-ref dx-config-chain-undocumented. +- `ask-codexhome-doc-mismatch` [usability] defaultCodexHomeURL doc promises Application Support for container apps, but code returns $HOME/.codex whenever HOME is set on macOS — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:71` + - Doc/README describe CODEX_HOME → ~/.codex for CLI runs → Application Support for container environments, but the #if os(macOS) branch returns $HOME/.codex whenever HOME is non-empty — true for every macOS process. Sandboxed apps get /.codex; the Application Support branch is effectively unreachable on macOS. Compile-time os(macOS) cannot express the documented runtime distinction. +- `ask-delta-random-item-identity` [bug] Command/file/tool progress deltas without itemId mint a fresh random item identity per delta — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:892` + - itemUpdate(from:kind:) uses `payload.itemID ?? UUID().uuidString`; an id-less delta becomes a distinct CodexThreadItem per notification (transcript spam, no merge owner). Agent-message deltas got a proper identity owner (fallback-ID promotion) but command/file/tool deltas did not. Dormant against v2 (item_id required upstream), but as written it fabricates identity instead of failing fast or degrading to .unknown. Cross-ref conf-command-delta-clobbers-item (content clobbering in the same function). +- `ask-readme-boundary-drift` [usability] README 'Boundary' list materially under-states the public surface — `CodexKit:Sources/CodexAppServerKit/README.md:489` + - The public-boundary list (~26 types) omits a large fraction of the actual public API (CodexReviewDelivery — used in documented signatures — CodexThreadSnapshot family, item content types, CodexAppServerError, CodexJSONValue, etc.; CodexDomainTypes.swift alone has 493 public occurrences). The list is the discoverability contract and has drifted; 'what am I allowed to depend on' gets a false answer. +- `ask-review-session-unfiltered-transcripts` [api-design] CodexReviewSession.messages/transcriptUpdates are not filtered to the review turn while events/logEntries/progress are — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:878` + - events/logEntries/progress filter through reviewEventMatches with terminalTurnID, but messages and transcriptUpdates delegate to eventThread with no turn filter — for inline reviews on a shared thread, concurrent turns' messages/items leak into two of the five sibling sequences. The inconsistency is the trap: consumers assume shared scope. +- `conf-batchwrite-okoverridden-invisible` [coverage-gap] config write result okOverridden is indistinguishable from ok; overriddenMetadata dropped — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:1704` + - Upstream ConfigWriteResponse distinguishes ok vs okOverridden (write landed but masked by a higher-precedence layer) with overriddenMetadata.effectiveValue. CodexKit decodes status as a bare String, updateConfiguration discards the response entirely, and config/read decodes only 4 keys with no origins/layers — a consumer writing into a masked layer believes the new value is live and cannot detect masking on read either. +- `conf-model-service-tier-order` [coverage-gap] CodexModel decode sorts service tiers lexicographically, merges the compatibility field, and drops tier metadata — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2695` + - `supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTiers ids)).sorted()` keeps consuming the deprecated field and does not preserve wire order. Upstream does not document that wire order as normative UI order, so ordering impact remains unverified. Dropping `ModelServiceTier` name/description is independently observable: UIs can show only raw ids. +- `conf-permissions-object-shape` [protocol-mismatch] Permissions.profileSelection encodes an object where upstream expects a plain profile-id string — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:387` + - Upstream thread/start.permissions (and resume) is `Option`. CodexKit can also encode `{type:"profile", id}` via package-scoped `.profileSelection`; pinned app-server maps the serde mismatch to `invalid_request` (-32600). The public `.profile(id:)` path conforms, so this is a latent package-internal wire break. +- `conf-review-steer-always-rejected` [api-design] CodexReviewSession publicly exposes steer() although upstream categorically rejects steering review turns — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:919` + - turn/steer on a review turn always fails upstream with activeTurnNotSteerable{turnKind: review}. CodexReviewSession.steer(with:) forwards to turn/steer — an API call that can never succeed, whose doc promises 'sends additional input to the running review turn', and whose typed error info is dropped (cross-ref conf-error-payload-discarded) leaving only an opaque message string. +- `conf-service-tier-tristate-collapsed` [protocol-mismatch] Double-option serviceTier tri-state is unexpressible (nil always means omit, never clear) — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:1299` + - Upstream serviceTier on turn/start, thread/start/resume/fork is Option> (omitted = unchanged, explicit null = clear). CodexKit models plain String? with synthesized Codable that omits nil — a consumer can set or leave unchanged but never clear. Other double-option fields upstream are not implemented by CodexKit, so the gap is confined to serviceTier. +- `cov-experimental-capability-hardcoded` [api-design] experimentalApi capability is hardcoded on with no public control, and public listTurns rides an experimental method — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:233` + - Initialize.Capabilities defaults experimentalAPI=true and Params.init uses .init() unconditionally; Configuration exposes no knob. The capability gates experimental client→server methods, filters experimental notifications, and strips some experimental fields from server-request payloads; it does not generally determine which request methods the server may send. Public stable-looking `listTurns()` rides experimental `thread/turns/list`, and `optOutNotificationMethods` is unrepresentable. +- `cov-invented-turn-status` [api-design] CodexTurnStatus invents a .cancelled case and alias decodings the v2 protocol never emits — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:1836` + - Upstream TurnStatus is exactly {completed, interrupted, failed, inProgress}; CodexTurnStatus adds .cancelled plus tolerant aliases ('started','succeeded','failure','aborted',...). `.cancelled` is unreachable from current wire, yet consumer code branches on it. A future status normally becomes `.unknown`; it could be misclassified only if its spelling collides with an alias. Smaller instance: CodexThreadItem.Kind invents .diagnostic/.error while upstream's real hookPrompt variant has no typed kind. +- `dk-fetchlimit-window-drift` [bug] Live-insert window logic lets items exceed fetchLimit via an unexplained loadedCount>fetchLimit growth branch — `CodexKit:Sources/CodexDataKit/CodexFetchRequest.swift:1044` + - loadedWindowItems grows the window when `insertedModel && (loadedCount < fetchLimit || loadedCount > fetchLimit)` — `!=` written as two comparisons. Once loadedCount exceeds fetchLimit (reachable via includePendingChanges merges without re-clamping), every further insert grows it again, so fetchLimit is not an upper bound — SwiftData's fetchLimit is a hard cap. The condition's shape suggests the >-branch was not a deliberate contract. +- `dk-model-field-mutability` [api-design] Server-owned model fields are publicly mutable with no persistence path — mutations are silently clobbered — `CodexKit:Sources/CodexDataKit/CodexModel.swift:393` + - CodexItem.kind/content/rawPayload, CodexTurn.status/errorDescription/itemsLoadState/usage, CodexChat.phase/lastErrorDescription and CodexFetchedResults.phase/lastErrorDescription are `public var` while every other server-derived field is private(set). There is no save(); the next snapshot/event merge overwrites app writes — worse, writing chat.phase/results.phase can corrupt the loading state machine DataKit itself reads back. +- `dk-model-for-workspace-name-mismatch` [api-design] model(for:) has placeholder-registering semantics for threads but lookup-only semantics for workspaces/groups — `CodexKit:Sources/CodexDataKit/CodexModelContext.swift:361` + - model(for: CodexThreadID) is non-optional and registers a placeholder; model(for: CodexWorkspaceID/CodexWorkspaceGroupID) are byte-for-byte identical to registeredModel(for:) — optional pure lookups. Same name, opposite contracts; README documents only the thread pair. +- `dk-refetch-per-revalidation-server-ordering` [architecture] Server-owned ordering turns every chat metadata change into a full listThreads round trip per registered results — `CodexKit:Sources/CodexDataKit/CodexFetchRequest.swift:857` + - With recencyAt/empty sort, usesServerOwnedOrdering makes revalidate/archive/remove always full non-appending refreshAfterMutation, and apply(event) revalidates on any ChatFetchedResultState delta (at least every turn start/completion of every chat, including background). The consumer's default sidebar descriptor is exactly this shape, so each change triggers listThreads per registered results. Local paths are worse: searchableText membership or offset>0 uses fetchAllThreadSnapshots which pages the entire thread history per scope. +- `dk-relay-unbounded-buffering` [bug] Update/transaction relays buffer unboundedly per consumer; a stalled consumer accumulates every delta — `CodexKit:Sources/CodexDataKit/CodexAsyncStreamRelay.swift:20` + - makeStream uses bufferingPolicy .unbounded; CodexChatUpdate streams carry per-token itemTextAppended events, so a consumer holding an iterator without draining grows its buffer without limit for the life of the observation. Updates are documented as invalidation hints over an observable current value, so superseded buffered updates have no value. +- `dk-subscribe-after-read-gap` [usability] updates stream starts buffering at first iteration, so the documented read-then-subscribe pattern is safe only without suspension in between — `CodexKit:Sources/CodexDataKit/CodexModelContext.swift:287` + - ObservationUpdates.makeAsyncIterator creates the stream lazily, so the README pattern (render chat once, then for-await updates) is race-free only if no await occurs between observe() returning and iteration; any suspension silently drops updates emitted in the gap — stale UI until the next event, no resynchronized marker. The current AppKit consumer happens to stay synchronous, so the contract is implicit and fragile. +- `dk-update-id-discontinuity` [api-design] CodexChatUpdate carries raw itemID strings whose identity changes on promotion; updates can reference ids never inserted — `CodexKit:Sources/CodexDataKit/CodexModel.swift:2001` + - Updates carry raw server itemID, not the stable CodexChatItemID. promoteFallbackMessageDeltaItem emits .itemUpdated under the NEW id with no .itemRemoved for the old or .itemInserted for the new; replaceItemAcrossTurns conversely emits remove+insert. A consumer keying by update ids sees updates for never-inserted ids and dangling fallback ids. The typed-id API shape invites correlation the identity model can't support; the stable CodexItem.id exists but isn't what updates carry. +- `dx-config-chain-undocumented` [usability] Executable resolution chain (3 env vars + PATH + hardcoded app paths) is undocumented — `CodexKit:Sources/CodexAppServerKit/AppServerProcessTransport.swift:942` + - Actual resolution: explicit executable arg > CODEX_APP_SERVER_CODEX_EXECUTABLE > CODEX_REVIEW_CODEX_EXECUTABLE > CODEX_EXECUTABLE > 'codex', searched against PATH plus hardcoded fallbacks (/Applications/Codex.app/Contents/Resources, homebrew paths). The public doc says only 'When nil, the default transport command is used' and the README documents only codexHome — debugging 'wrong codex picked up' requires reading package-internal source. Cross-ref ask-clientversion-default-mismatch for the related handshake-default drift. +- `dx-no-compat-policy` [api-design] No compatibility policy, no deprecation annotations, no runtime version — tolerant decoding is a hope, not a contract — `CodexKit:README.md:1` + - Decode tolerance is genuinely strong (.unknown catch-alls, rawPayload retention), but nothing states the rules: no README section on what ships in minors, nothing saying 'new enum cases are non-breaking — handle .unknown', zero @available(deprecated) usages, no runtime-inspectable version. Cheap artifact that converts the .unknown convention into an enforceable contract before a second consumer appears. +- `dx-request-id-not-exposed` [api-design] JSON-RPC request ids are logged but never attached to errors or results — no correlation handle for app logs — `CodexKit:Sources/CodexAppServerKit/AppServerClient.swift:160` + - The client allocates monotonically increasing request ids and logs them at debug level, but responseError carries only (code, message), CodexAppServerError carries no id, and typed results carry no envelope — correlating a UI failure with server/SDK log lines requires timestamp reconstruction. Cheap to add since ids already flow through the single send path. +- `test-store-invented-error-code` [protocol-mismatch] Test thread store returns -32004 for missing app-server threads, unlike the real missing-thread path — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:126` + - `resumeThreadResponse` / `readThreadResponse` / `listThreadTurnsResponse` throw -32004, while the pinned app-server missing-thread path returns -32600. Other Codex components use -32004, so the mismatch is scoped to this fake versus app-server thread lookup; error-classification tests against the fake would still pin the wrong contract. +- `use-cleanup-nested-array` [api-design] cleanupReview's [[CodexThreadID]] parameter forces awkward array-wrapping at the call site — `CodexKit:Sources/CodexAppServerKit/CodexAppServer.swift:564` + - The public signature takes additionalCleanupThreadIDs: [[CodexThreadID]] (an internal detail of orderedReviewCleanupThreadIDs leaking into the API). The consumer wraps its flat list in a single-element outer array, which reads like a bug and invites flattening mistakes. +- `use-empty-turnid-sentinel` [workaround] nilIfEmpty defenses against SDK-synthesized empty-string turn IDs — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:779` + - CodexKit's router fabricates CodexTurnID(rawValue: "") when a notification lacks turn context, so consumers treat empty string as nil at every identity boundary: appServerReviewIdentity, reviewThreadID comparisons, MCP guards, and ReviewBackendEventSession filters. An Optional-typed SDK contract deletes all of these. +- `use-listed-chat-status-absence` [usability] Sidebar treats absent chat.status as 'finished' by documented convention, replicated at three UI sites — `CodexReviewKit:Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift:117` + - CodexChat.status is nil for listed-but-unloaded chats, so the filter classifies anything not actively running as finished per its own comment; the convention is copied in the row view and context menu. Exists because the SDK list surface can't distinguish 'unknown' from 'idle' — a chat whose status hasn't loaded yet presents as finished. +- `use-test-polling` [test-gap] Test suites poll with yield/sleep loops because the observation pipeline has no drain signal — `CodexReviewKit:Tests/ReviewUITests/ReviewUITests.swift:5414` + - waitForCondition spins on Task.yield() under a 2s timeout at ~40 call sites; MCP HTTP tests use a 50ms sleep loop. There is no way to await 'all pending CodexChatUpdate/fetched-results transactions applied', so every model-driven UI assertion is time-based, importing load-proportional flakiness. Test-side face of the observation-updates gap. diff --git a/Docs/mcp.md b/Docs/mcp.md index cc54a2ad..03abd540 100644 --- a/Docs/mcp.md +++ b/Docs/mcp.md @@ -51,15 +51,6 @@ Returns: - `hasFinalReview` - `finalReview` from the terminal Codex review response - `reviewResult` parsed finding state (`hasFindings`, `noFindings`, or `unknown`) with title/body/location fields when a final review is available -- `log` - - `orderedEntryIds` - - `activeEntryIds` - - `activeEntryCount` - - `latestEntryId` - - `finalLifecycleMessage` - - `finalResult` - - `itemsPage` - - `items` for detailed reads Notes: @@ -93,8 +84,7 @@ Inputs: - `runId` or `runID` Returns the same lightweight shape as `review_start`: `runId`, `run`, -`lifecycle`, `review`, and a compact `log`. Use `review_read` when log item -details are needed. +`lifecycle`, and `review`. Use `review_read` when log item details are needed. If the run is still running after the bounded wait, call `review_await` again with the same `runId`. @@ -110,8 +100,13 @@ Returns: - `run` - `lifecycle` - `review` -- `log` with ordered item IDs, active item IDs, terminal lifecycle/final review - values, paging metadata, and item details. +- `log` with a bounded tail page: + - `items` in display order + - `itemsPage` paging metadata + +Lifecycle and final review values remain in their top-level owners. Detailed +log responses do not duplicate them or expose internal snapshot revision and +entry-index fields. ### `review_list` diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md new file mode 100644 index 00000000..15c4dfb1 --- /dev/null +++ b/Docs/rearchitecture-2026-07-10.md @@ -0,0 +1,4169 @@ +# CodexKit / CodexReviewKit / MCP dependency rearchitecture design(2026-07-10) + +| 項目 | 内容 | +|---|---| +| Status | **Approved — design gate 承認済み(2026-07-10)** | +| Evidence baseline | [`design-audit-2026-07-10.md`](design-audit-2026-07-10.md) | +| CodexKit baseline | `3f6216c01c91bf14737e6fe40c30efef7a5bbd04` | +| CodexReviewKit rollback point | `baabd19cbda014dc869f34e0c18121a9fc33a65d` | +| upstream contract | codex `8347b8de2144133f49fd18a20f4ca176deef1d3c` | +| MCP dependency baseline | `modelcontextprotocol/swift-sdk` `0.12.1` / `a0ae212ebf6eab5f754c3129608bc5557637e605`(request child join欠損。fork revisionはdesign承認後に作成して記録) | +| Toolchain | Xcode 26.6 / Apple Swift 6.3.3 | + +この文書は `rearchitect` Phase 2 の canonical design doc である。実装中に API、owner、依存方向、互換方針を変える必要が生じた場合は、実装より先にこの文書を更新して design gate の差分を確認する。 + +## 1. Scope contract + +### Outcome + +この移行の成果は、次の consumer story を推測や fallback なしで完遂できることである。 + +1. ReviewMonitor が current upstream v2 の turn outcome、connection termination、login、review identity を typed contract だけで処理できる。 +2. CodexDataKit の query、mutation、load、observation がそれぞれ単一 owner を持ち、同じ判断を call site ごとに再実装しない。 +3. fake / preview / test が production と同じ wire DTO、request lifecycle、list semantics、cancellation semantics を通る。 +4. app-server process、router task、server-request task、observation task、MCP protocol request task の全 retaining handle から明示的な close completion へ到達できる。 +5. ReviewMonitor app と MCP server が、同じ typed review identity と CodexChat projection を使用し、review output や thread identity を別々に再導出しない。 + +### Compatibility policy(承認対象) + +- この変更系列では **source-breaking migration を許可**し、CodexKit と repo 内の全 CodexReviewKit consumer を同時に移行する。repo 内互換 shim は置かない。 +- runtime wire contract は pinned upstream の **current v2 only** とする。`turn/failed`、`turn/cancelled`、`item/updated`、`agent/message`、legacy `item/fileChange/outputDelta` を通常 router と default fixture から削除する。future/malformed schema を検証する raw test escape hatch は明示 API として残す。 +- MCP の外部 tool 名と response field shape(`review_start` / `review_await` / `review_read` / `review_list` / `review_cancel`)は維持する。内部 identity の nested type 化は MCP boundary で既存 field へ flatten する。 +- ReviewMonitor の product cancellation-wins、review worker generation guards、MCP session contract、UI の見た目は維持する。 +- DataKit sort surfaceは Foundation `SortDescriptor` から `CodexSortDescriptor` へsource-breaking移行し、optional DateとString comparatorを含む現行consumer storyを維持する。 +- Review rollout表示はexact UI維持を優先し、typed companion relationで候補を限定した1箇所のtext equalityだけを§13のupstream affordance待ちとして残す。reasoning/general text pairingとraw payload decodeは削除する。 +- CodexKit の repository 外 consumer は確認できていない。互換 shim の根拠となる実在 consumer はない。clean external fixture は consumer need の証拠ではなく、public product の compile/link contract proof として追加する。 +- `thread/rollback` の upstream replacement と structured interrupt-race error は pinned upstream に存在しない。両項目は §13 の外部契約待ちとして隔離し、存在しない replacement を発明しない。 +- MCP Swift SDK 0.12.1の`Server`はrequest childrenをjoinしないため、公式sourceの最小forkをexact revision pinしてawaitable stop contractを追加する。CRKの`.build` checkoutだけをpatchする一時解は採用しない。 + +### Consumers + +| Surface | 第一 consumer | 第二 consumer | Verification | +|---|---|---|---| +| `CodexAppServerKit` | `CodexReviewAppServer`: review session lifecycle | `CodexReviewHost`: account/model/login/connection lifecycle | 別 package `Fixtures/CodexKitProductConsumer` | +| `CodexDataKit` | ReviewMonitor UI | MCP CodexChat projection | 同 fixture | +| `CodexAppServerKitTesting` | CodexKit tests | CodexReviewKit preview / tests | deterministic external fixture run | +| `CodexReviewKit` core | ReviewMonitor app | `CodexReviewMCPServer` | module tests + app tests | +| forked MCP `Server.stop()` | `CodexReviewMCPServer` HTTP lifetime | fork package contract tests | exact-revision dependency fixture + CRK stop tests | +| private `LoginSession` | `LiveCodexReviewStoreBackend` | なし。public abstraction に昇格しない縮退モード | Host lifecycle tests | +| `ReviewChatLogUI` presentation owner | ReviewMonitor detail UI | なし。typed input を consume する internal owner として維持 | ReviewUI tests | + +private `LoginSession` と presentation owner には第二の外部 consumer がない。再利用 framework 化を目的にせず、第一 consumer の call site と state ownership の改善だけを acceptance とする縮退モードを適用する。この縮退も design gate の承認対象である。 + +### Non-goals + +- Appendix B の未検証 low 35 件。 +- `TextTransitions`、ReviewMonitor の visual redesign、Tools の配布方式。 +- client-initiated `fs/*`、`mcpServer/*`、skills、plugins、exec/process、realtime、remoteControl の新 surface。pinned current-v2 が送る inbound `mcpServer/elicitation/request` の response contract は server-request lifecycle の一部としてscope内。 +- raw JSON-RPC send escape hatch。 +- 外部 process 変更を自動 ingest する live list。今回の freshness contract は「同一 context の mutation は live、外部変更は explicit refresh」で固定する。 +- upstream にまだない rollback replacement や structured interrupt error の独自 protocol。 +- push、PR、release、tag。local implementation と commit を完了する。portable fork revision pinにはremote fork publishが必要なため、design承認後の実装中にその外部変更だけ別の明示承認を取る。 + +## 2. Phase 1 baseline + +### Repositories and tests + +- CodexReviewKit HEAD `baabd19`、local CodexKit HEAD `3f6216c`、MCP Swift SDK exact 0.12.1、exact audit checkout と upstream checkoutをbaselineにする。 +- CodexKit `swift test --build-system swiftbuild --no-parallel`: pass。 +- CodexReviewKit 同 command: `detailLogKeepsBottomFilledForMultilineStreamDuringLiveWindowResize` が全体実行で 1 回失敗し、単独再実行は pass。監査対象外の既存 flake として移行後比較に残す。 +- Phase 1 instrumentation は使用していない。 + +### Topology and size + +| Repository / target | Swift files | LOC | access baseline | +|---|---:|---:|---| +| CodexAppServerKit | 10 | 10,802 | public 597 / package 718 / private+fileprivate 307 | +| CodexAppServerKitTesting | 1 | 1,130 | public 67 / package 9 / private 29 | +| CodexDataKit | 11 | 8,888 | public 203 / package 161 / private+fileprivate 309 | +| CodexReviewKit | 33 | 6,413 | public 97 / package 523 / private 182 | +| CodexReviewAppServer | 2 | 897 | package 21 / private 52 | +| CodexReviewHost | 4 | 2,852 | public 27 / package 18 / private 135 | +| CodexReviewMCPServer | 10 | 2,144 | package 47 / private 73 | +| ReviewChatLogUI | 13 | 11,289 | package 10 / private 525 | +| ReviewUI | 30 | 8,093 | public 9 / package 37 / private 406 | + +Largest owner files: + +- CodexKit: `CodexDomainTypes.swift` 2,873、`AppServerRequests.swift` 2,342、`CodexAppServerNotificationRouter.swift` 1,572、`CodexModelContext.swift` 2,714、`CodexModel.swift` 2,648。 +- CodexReviewKit: `LiveCodexReviewStoreBackend.swift` 2,127、`CodexReviewStoreReviews.swift` 1,401、`AppServerCodexReviewBackend.swift` 879、chat-log projection/target 816/861。 +- platform gate は CodexKit 1 file、CodexReviewKit 0 files。残る 1 件は local-process default home の macOS composition policy である。 +- CodexKit umbrella target に `@_exported import` が 2 件ある。production source の他の re-export はない。 + +### Numbered findings + +1. **RA-01 — umbrella boundary**: `CodexKit` product が `CodexAppServerKit` と `CodexDataKit` を `@_exported import` で再結合し、consumer の依存選択を失わせている。 +2. **RA-02 — terminal outcome owner**: `CodexTurnStatus.isFailure` と collector/progress/DataKit/CRK が completed/interrupted/failed/caller cancellation/invalid terminal を別々に分類する。 +3. **RA-03 — request error owner**: transport、client、factory の error が public taxonomy に写像されず、JSON-RPC `data`、request ID、method、decode、spawn、deadline が失われる。 +4. **RA-04 — resource lifecycle owner**: router task cycle、await しない stop、ownerless cancellation/server-request tasks、process backstop 不在、raw history と serializer lane の無期限保持が同じ connection lifecycle に接続されていない。 +5. **RA-05 — wire compatibility owner**: invented native login、protocol-invalid default `{}`、`serverRequest/resolved` 無視、current/historical notification 混在に明示 policy がない。 +6. **RA-06 — item/account merge owner**: command delta / file snapshot / optional item ID / sparse rate-limit update が境界で意味付けされず、partial value が authoritative snapshot を上書きする。 +7. **RA-07 — query mutation/load owner**: mutation strategy が handler ごとに散り、concurrent `load()` が cursor/items/phase を古い generation から commit できる。 +8. **RA-08 — query contract**: predicate/sort/section lowering が render 中に crash し、archived scope と external freshness が暗黙、sort signature は reflection に依存する。 +9. **RA-09 — fake and fixture contract**: fake list は archived/sort を無視し、unstubbed request と cancellation を成功化し、current wire DTO が tests/preview に複製される。 +10. **RA-10 — connection/observation lifecycle**: warning/retry/deprecation/termination は raw/side-channel、chat observation は一 slot で release completion を await できない。 +11. **RA-11 — login state owner**: Host の 9 state values と 8 reset clusters に単一 terminate owner がない。 +12. **RA-12 — review output owner**: adapter/store/MCP が optional output、3 段 fallback、run storage、projection absence と refresh failure を重複処理する。 +13. **RA-13 — review identity owner**: source/active thread と attempt identity が optional String 群へ type erase され、`"attempt-1"` と resume-to-cancel が第二経路になる。 +14. **RA-14 — presentation input**: typed item kind/content/turn relationを捨て、raw payload/text equality、duplicated marker ID、full re-projection、item status 再導出を UI が所有する。 +15. **RA-15 — dead/test-only surface**: 未使用 `CodexReviewHost` class/direct backend と 107/124 mechanical testing forwarders が owner boundary を曖昧にする。 +16. **RA-16 — upstream affordance gaps**: deprecated rollback と interrupt race の string error は local owner 整理だけでは意味論を完結できない。 +17. **RA-17 — MCP dependency lifecycle**: Swift SDK `Server.start`がrequestごとにuntracked Taskを生成し、`Server.stop()`がreceive/request childrenをjoinしないため、CRK ownerだけではshutdown completionを証明できない。 + +## 3. Target / product design + +### Candidate A — existing packages, direct products, owner types(推奨) + +- CodexKit と CodexReviewKit の別 package は維持する。別 repo、独立 version/pin、external consumer という package 境界の事実がある。 +- CodexKit 内は `CodexAppServerKit`、`CodexDataKit`、`CodexAppServerKitTesting` の 3 products を consumer が直接選ぶ。 +- `CodexKit` umbrella product/target/test を削除する。 +- AppServerKit 内の transport/client/router/connection/replay は同 target の package/internal owner として分ける。public consumer story は 1 つであり、wire/runtime target を増やす配布上の根拠はない。 +- CodexReviewKit の既存 target graph は維持し、Host class ではなく production composition function/store を entry point とする。 + +### Candidate B — AppServerKit を facade/wire/runtime targets に分割 + +依存方向を compiler で強制できる一方、同じ public session story・release cadence・platform・consumer を持つ 718 件の package contract を複数 target に固定し、wire DTO、router、client の lockstep 変更を常に跨がせる。独立 consumer も dependency isolation も確認できないため、現時点では file-bucket target 化のコストが勝つ。 + +### Decision + +Candidate A を採用する。構造上の決め手は「別 product を選ぶ実 consumer は存在するが、AppServerKit 内部 owner を別 target として選ぶ consumer は存在しない」ことである。 + +```mermaid +flowchart LR + ProductConsumer["External product fixture"] --> ASK["CodexAppServerKit product"] + ProductConsumer --> DK["CodexDataKit product"] + ProductConsumer --> TESTING["CodexAppServerKitTesting product"] + + DK --> ASK + TESTING --> ASK + + Adapter["CodexReviewAppServer"] --> ASK + Adapter --> DK + Adapter --> Core["CodexReviewKit"] + Host["CodexReviewHost composition"] --> ASK + Host --> DK + Host --> Core + Preview["ReviewUIPreviewSupport"] --> ASK + Preview --> DK + Preview --> TESTING + UI["ReviewUI / ReviewChatLogUI"] --> DK + UI --> ASK + UI --> Core + MCP["CodexReviewMCPServer"] --> DK + MCP --> ASK + MCP --> Core + MCP --> MCPSDK["fork-pinned MCP Swift SDK"] +``` + +umbrella 削除後の `Package.swift` は次の direct-product matrix に固定する。umbrella import で偶然見えていた ID、item、status は `CodexAppServerKit` の直接依存として宣言する。 + +| CodexReviewKit target | `CodexAppServerKit` | `CodexDataKit` | `CodexAppServerKitTesting` | +|---|:---:|:---:|:---:| +| `CodexReviewKit` | — | — | — | +| `CodexReviewAppServer` | ✓ | ✓ | — | +| `CodexReviewMCPServer` | ✓ | ✓ | — | +| `CodexReviewHost` | ✓ | ✓ | — | +| `CodexReviewTesting` | ✓ | — | ✓ | +| `ReviewUI` | ✓ | ✓ | — | +| `ReviewChatLogUI` | ✓ | ✓ | — | +| `ReviewUIPreviewSupport` | ✓ | ✓ | ✓ | +| AppServer / Host tests | ✓ | 必要な SUT API に限る | ✓ | +| MCP tests | ✓ | ✓ | fixture を使う場合のみ ✓ | +| ReviewUI tests | ✓ | ✓ | ✓ | + +CodexKit 自身では `CodexDataKit -> CodexAppServerKit`、`CodexAppServerKitTesting -> CodexAppServerKit` の既存方向を維持する。ただし Swift module import は再 export ではないため、上表の consumer は source で直接使う module をすべて明示 import する。 + +`CodexAppServerKit` target 内の owner direction: + +```text +CodexAppServer facade + -> ConnectionSupervisor (only close authority, shared completion, leases) + -> AppServerConnection (actor-confined client/router/reducer/resource references) + -> AppServerClient (request ID, encode/decode, deadline, error mapping) + -> JSONRPCTransport (framing/I/O/process only) + -> AppServerNotificationDecoder (single exhaustive current-v2 switch) + -> NotificationRouter (typed routing only; Task を所有しない) + -> TurnReplayStore / ThreadEventHub / AccountEventHub / ServerRequestRegistry +``` + +## 4. Target owner map + +| Owner | State / authority | Invariant | +|---|---|---| +| `ConnectionSupervisor` | child exit channel、router/control Task handles、shared close task | 唯一のTask/close authority。single shared lease objectをretainせず、childはexit signalだけをactor methodへ送り、supervisor-owned close Taskがfull closeを実行する | +| `AppServerConnectionLease` | connection全handleで共有するstrong supervisor reference、`ProcessTerminationToken` | handle→single lease→supervisorの一方向。explicit async closeだけがfull closeを保証し、last copy deinitは同期process terminate backstopだけを実行する | +| `AppServerConnection` | connection state、transport、router/reducer/client values、registry references | child/close/handler Task handlesを所有せず、supervisorが実行するchild bodyとphase operationを提供する | +| `ProcessTerminationToken` | process group identity と同期 terminate signal | explicit close を primary とし、deinit は child-process leak の backstop | +| `JSONRPCTransport` | frame read/write、inbound envelope stream、close phases、process reap | live transportはI/O reader/drain/process waiterだけ、test transportはin-memory channel waiterだけを所有し、domain decode/handler Taskを所有しない | +| `AppServerClient` | request ID、request deadline、decode/error mapping | request failure は requestID/method/data を失わない。`CancellationError` を包まない | +| `RequestSerializer` | scoped lane occupancy とcancel-aware waiter continuation | caller Taskがoperation completionを所有し、idle laneはlast leave/cancel後に除去 | +| `RequestOperationState` | pre-write / written / response-bound / cleanup-complete commit point、caller-cancel bit | caller cancellationとwire operationを分離するchecked `Sendable` value。post-writeはresponse/required cleanupまでlaneを保持してから`CancellationError`を返す | +| `AppServerNotificationDecoder` | raw method/params → current-v2 domain event | 単一 exhaustive switch。historical alias、malformed payloadを正常eventへ補完しない | +| `NotificationRouter` | typed thread/turn routing | run Task、subscriber、generation checkpoint、terminal historyを所有しない | +| `TurnReplayStore` | active raw generation、weak generation-state registrations | terminal compact snapshotをstateへ渡してraw generationを削除し、自身はterminal snapshot/leaseをretainしない | +| `ThreadEventHub` | reusable threadごとのcurrent compact generation、request前generation checkpoint、bounded subscribers | global append-only historyを持たず、early eventをcheckpointへroutingしてsuccess時だけcurrent generationへcommit。generation reset/connection closeで旧compact stateを解放する | +| `TurnGenerationHandleState` | live connection lease / terminal compact snapshot / connection terminationの排他的state | public/package handle copyが共有する唯一のgeneration transition owner。terminalでleaseをnil化し、replay storeからはweak registrationだけを受ける | +| `TurnOutcomeClassifier` | terminal response → exhaustive outcome | output の有無から unknown/nil/running を success にしない | +| `ServerRequestRegistry` | `CodexServerRequestID` → handler Task/responder | handler Taskの唯一のowner。resolved/closeでcancel + await、method-specific responseのみencode | +| `AccountEventHub` | last rate-limit snapshot、subscribers | sparse update mergeを型自身の `merging` 1 箇所で所有 | +| `ConnectionEventHub` / `ConnectionTerminationArbiter` | supervisor isolation内のbounded diagnostics、subscribers、first terminal reason | routerにhistoryを持たせず、late subscriberへcompact terminal 1件だけreplay。arbiterは`ConnectionSupervisor` actor-confined valueとして最初のsignalをawaitなしでclaimし、別actorへのhopで受理順を変えない | +| `LoginRegistry` / `LoginHandleState` | active login ID + weak state registration、ID-correlated winner、post-success account-readiness barrier、cancel/lease | login winner/readinessの唯一owner。registry→state cycleを作らず、broad account streamへcompletionを流さない | +| `CodexItemReducer` | current item snapshot + typed delta/snapshot | command append、patch snapshot、metadata preservationを1 ownerで実行 | +| `CodexThreadQueryPlan` | validation、archive scope、server/local filter、effective sort + stable typed-ID tie-break、mutation strategy | query/mutation/orderの意味論を call site に漏らさない。created/updatedはstable recency cursorで全件列挙後にlocal sort | +| `CodexModelContainer` / package `CodexModelContextCoordinator` | borrowed app server、eager MainActor context、context-family association | facadeは`Equatable` + checked `Sendable`でserver close authorityを持たない。contextは内部coordinatorを保持し、facade lifetimeへmulticastを依存させない | +| `CodexDefaultSerialModelExecutor` | private model context とserial job queue | `@unchecked Sendable`をこの型に限定し、contextをpublic getterからescapeさせず全accessを同executor jobへ閉じる | +| `FetchedResultsLoadCoordinator` | queued intent、cursor/window、in-flight completion | load intentを直列化し、stale completionをcommitしない | +| `CodexFetchedResults` | descriptor/context、current items/sections/phase、newest full old/new snapshot transaction、subscribers | query/current value/observationの単一owner。relayはbuffer newest 1、consumer mismatchはnew snapshot replace、owner deinitでfinish | +| `ChatObservationOwner` | one upstream pump、subscriber leases、upgrade-to-include-turns、close task | multi-subscriber、last closeでpump cancel + await | +| `ChatObservationReleaseSignal` | lock-protected lease-ID event queue、receiver waiter、terminate bit | `CodexChatObservation.close/deinit`からactor hopなしで同期sendできるchecked `Sendable` endpoint。ownerだけがreceiverをdrainする | +| `CodexAppServerTestThreadStore` | snapshots、archived membership、order、mutations、explicit planned start/fork fixtures | list/read/resume/archive/deleteが同じ fake stateを見る。requestからrequired ID/clock/runtime metadataをfabricateしない | +| `CodexAppServerTestNotificationEmitter` | opaque Testing current-v2 fixture + shared wire DTO encoder + test transport ingress | lossy production snapshot/reducer aggregateからwireを再構成せず、Testing fixtureが保持するcanonical DTOだけをencodeする | +| `CodexAppServerTestServerRequestInjector` | test request envelope/response completion | shared codec、registry、handler、response encoderを必ず通る | +| CRK `ReviewAttempt` | attempt ID、source/active thread、turn、model | backend attempt確立前はqueued。running/recoveryは必ずattemptを持つ | +| Host `LoginSession` | immutable purpose、operation state、root Task、shared termination completion | runtime/handle/challenge mirrorを持たず、internal Taskはterminateを呼ばない。全外部exitがreason付きasync terminateへ収束 | +| Host `LoginOperationState` | explicit phase、runtime ownership、handle、provisional terminal decision、eventual result、cleanup handoff | root TaskとMainActor sessionの共有owner。session/rootを相互retainせず、purpose別commit pointとlate bindを1つのstate machineで裁定する | +| Host `AccountRegistryStore` | registry schema/migration、immutable auth revisions、shared `auth.json` activation、active selection、account metadata、orphan GC、serialized mutation lease | load/switch/remove/sign-out/add-account/sign-in reconciliationの唯一のpersistence owner。active LoginSession中の別mutationをtyped rejectし、atomic registry replace前後のcrash semanticsを全operationで統一する | +| Host `AccountRuntimeTransitionCoordinator` | mutation/login lease、expected account/signed-out state、runtime-preserving stop/start completion、login final resolver、final-shutdown arbitration、UI publication | shared-auth mutation・unconfirmed primary login・top-level final stopとprimary AuthManager cacheの唯一のbridge。leaseをnew runtime account validationまたはdurable reconciliation-debt handoffまで保持し、double stopとold runtime eventをgeneration rejectする | +| Host `HostRuntimeSession` | accepted runtime generation、app server/model container、connection/account subscriptionsとconsumer Task handles、lifecycle callback state、shared stop completion | runtime task/callback/closeの唯一owner。nonnil/nil callbackとevent commitをgeneration-guardし、consumer childはstopを呼ばずexit signalだけ返す | +| Preview `PreviewRuntimeLifetime` | preview stream/notification Task handles、Testing runtime/container、shared stop completion | preview store backendが所有し、`CodexReviewStore.stop()`でcancel + await + TestRuntime closeを完遂する | +| `ReviewRestartCoordinator` | token state、deprecated `thread/rollback` exactly-once、restart shared completion/retry budget、retained cleanup identities | review restartの唯一owner。token single-use/invalid化とcleanup-vs-restartを裁定し、deprecated wire methodをconsumerへ漏らさない | +| `InterruptRaceResolver` | terminal-known fast path、pinned upstreamのstale/no-active message分類、固定retry budget | interrupt raceのcompat判断を1 ownerへ隔離し、parser/retry stateをSDK/CRK consumerへ複製しない | +| CRK adapter | SDK outcome/identity/error → transport-independent CRK event/attempt/failure | mapping は境界で1回だけ。output欠損もtyped failureのままstoreへ届く | +| CRK `ReviewThreadRetentionRegistry` | in-memory pending ownership/run ID + account/home + source-ordered identities、crash-cleanup journal、unpersisted cleanup quarantine、retirement completion | terminal projection/thread lifetimeをin-memory store run lifetimeへ揃える。known identityをpublication前にclaimし、routine runtime restart/account switchではdeleteせず、final store retirementまたはjournal-commit failure cleanupだけが`cleanupReview` authority | +| CodexChat projection | review output / log content | run lifecycle は transcript textを保存しない | +| `ReviewOutputPublicationBarrier` | transient completed output + terminal DataKit cursor/refresh completion | `.succeeded` publish前にCodexChat projectionへ同一nonempty outputが到達したことを検証し、完了後はtextを保持しない | +| MCP `MCPReviewSessionRegistry` | session open/closing/closed、session→run membership、shared close completions | run/session isolationとsession-close cancellationの唯一owner。closed linearization後はstart/read/await/cancelを受理しない | +| MCP `MCPHTTPServerLifetime` | listener/channel、cleanup/timeout、per-session transport/request/heartbeat Task handles、shared stop completion | HTTP/MCP長寿命Taskの唯一owner。stream callbackからownerless Taskを作らず、server stopで全session/taskをdrain | +| forked MCP protocol `Server` | receive-loop structured task group、request children、pending request continuations、shared stop completion | `stop()` return時にprotocol request child 0。handlerはstopを自己awaitせずexit signalだけ返す | +| CRK store cancellation arbiter | pending product cancellation、per-run cancellation operation handle、shared completion/waiters | accepted cancel commandをstore-owned completionまで進め、backend terminalとのraceでは既存 cancellation-winsを維持 | +| CRK `ReviewStoreRuntime` / `ReviewStoreCommitSink` | worker/cancellation Task handlesとcycle-free weak MainActor commit endpoint | store→runtime→Task、Task→immutable backend/run values + weak sinkの一方向。Taskはstore/runtimeをstrong captureしない | +| CRK `ReviewWorkerState` | attempt generation、typed execution phase、network phase、result/recovery child state、prepared restart token、held connection terminal | closed signal enumを1 parent task groupで裁定し、stale generationを拒否する | +| CRK `ReviewRunPresentation` | durable `ReviewRunCore` + transient `ReviewExecutionPhase` → status/lifecycle/cancellable | UI/MCP表示の唯一owner。stored String/mirror stateを持たない | +| `ReviewTurnPresentationPolicy` | per-turn review marker membership、user-prompt visibility、companion policy適用 | cross-item visibilityをtyped turn factsからtargeted reconcileし、document全再投影しない | +| `ReviewRolloutPresentationPolicy` | typed companion/target pairのdisplay equality | §13のscoped text比較の唯一owner。semantic identity/mergeには使わない | +| ReviewChatLogUI projection | typed item presentation | raw JSON/text identityをsemantic identityにしない | + +### 4.1 Isolation and task ownership contract + +各 worker は次の isolation / task lifecycle をそのまま実装する。表にない型が長寿命 Task、continuation、subscriber、process、lease を保持し始めた場合は、実装を進めず owner map を更新して design gate を再確認する。 + +| Owner | Isolation | Task creator | Task handle / completion owner | +|---|---|---|---| +| `ConnectionSupervisor` | package actor | `AppServerConnection.runInbound` router childと`transport.waitForProcessExit()` control childを起動 | child handles、shared close Task、first exit signal、full-close completionの唯一owner。childは`recordExitSignal` actor methodへreasonを渡すだけでfull closeをawaitしない。methodがsupervisor-owned close Taskをcreate/joinし、そのTaskはchild handle集合に含めない | +| `AppServerConnection` | supervisorから生成されるpackage actor | Taskを作らずrouter/control child bodyとphase operationを提供 | Task handle/completionを保持しない。registry actor/transport等のresource referenceだけを保持 | +| `AppServerConnectionLease` | `Synchronization.Mutex` stateを持つchecked `Sendable` final class | Taskを作らない | connection全handleが同一instanceを共有し、supervisor + process token + terminate-once bitをmutex内に閉じる。deinitは`ProcessTerminationToken.terminateOnce()`だけでactorへsignalしない。supervisorはleaseをretainしない | +| `ProcessTerminationToken` | lock-protected synchronous value | Taskを作らない | process-group identityとterminate-once bitだけを所有。async process waiter/reap completionはlive transportが所有 | +| live `ProcessJSONRPCTransport` | package actor | stdout reader、stderr drain、process waiter Tasksを生成 | reader/drain/waiter handles、pending response continuations、inbound finish completionのowner。`beginClose`はnew I/O拒否、stdin close、`ProcessTerminationToken.terminateOnce()`を開始するがbuffer済みresponseをdrainするまでrequest continuationをfinishしない。`waitUntilClosed`はreader/channel drain、`reapProcess`はprocess waiter completion | +| test `InMemoryJSONRPCTransport` | package actor | 必要なin-memory inbound pumpだけを生成 | pump handle、request/notification waiters、inbound finish completionのowner。process token/reapは持たない | +| `AppServerClient` | `AppServerConnection` actor-confined value | request/retry/deadlineはcallerのstructured task groupだけ | stored Taskを持たない。handshake completionまでfacade/leaseを公開せず、shared initialize Taskを削除 | +| `CodexItemReducer` / `NotificationRouter` | `AppServerConnection` actor-confined values | Taskを作らない | request waiterは`RequestSerializer`、subscription/replayは専用ownerへ渡す | +| `RequestSerializer` | package actor | Taskを作らない | queued waiterはcancel時即remove。active laneはpre-write cancelだけ即releaseし、written後はcorrelated response + method-required cleanupまで保持する。same operationのcleanupだけがscoped lane tokenでreentrant sendでき、last leave後にlaneを削除 | +| `RequestOperationState` | `Synchronization.Mutex` checked `Sendable` value | callerがcancellation-shielded local operation Taskを1つ生成しhandleをscope終了までawait | write acceptance/response identity/caller-cancel/cleanup completionをexactly once記録。Task handleを保存・detachせず、callerはcancel後もcompletionをawaitする | +| `TurnReplayStore` | package actor | Taskを作らない | live generation subscribers + weak handle-state registrationsを所有し、terminal snapshotをstateへ渡してraw historyを解放 | +| `ThreadEventHub` | `Synchronization.Mutex` stateを持つchecked `Sendable` final class | Taskを作らない | threadごとのcurrent compact generation、request前checkpoint、bounded subscriber channel、connection failure terminalを所有。subscription cancellation endpointは同期remove/resumeしactor hopを作らない | +| `TurnGenerationHandleState` | package actor | Taskを作らない | `.live(lease) → .terminal(compactSnapshot)` または `.terminated(error)` を一度だけ遷移し、live leaseを同transactionでrelease。collect/cancel/closeConnectionが同stateを読む | +| `ServerRequestRegistry` | package actor | configured handler Taskを生成 | handler handle/responderの唯一owner。resolved/closeでcancel + await | +| `AccountEventHub` | package actor | Taskを作らない | subscriber continuationとfinish completionを所有 | +| `ConnectionEventHub` / `ConnectionTerminationArbiter` | `ConnectionSupervisor` actor-confined package value。subscriber cancellation endpointだけ`Synchronization.Mutex` checked `Sendable` | Taskを作らない | bounded diagnostic subscriber、terminal replay、first-terminal-wins reasonを所有。supervisorのstored `firstTermination` / `firstDomainError`はarbiterへ置換し、独立actorを追加しない | +| `LoginRegistry` | package actor | cancel requestはcaller/LoginSessionのstructured Taskだけ | active ID + weak stateだけを保持し、routerが一時strong化してmatching IDへresolve | +| `LoginHandleState` | `Synchronization.Mutex` stateを持つchecked `Sendable` final class | Taskを作らない | pending/bound/successAwaitingAccountUpdate/terminal、result waiter collection、connection leaseをmutex内に閉じる。success winnerは後続account updateまでwaiterを保持し、各caller cancellation/terminalがexactly once resumeする | +| `CodexModelContainer` / package `CodexModelContextCoordinator` | containerは`Equatable` + checked `Sendable`、coordinatorは`@MainActor`。mutable main contextも`@MainActor` | Taskを作らない | containerはborrowed app serverとmain contextをeager保持。各contextはcoordinatorをstrong保持し、coordinatorはmain contextをweak登録するためcycleを作らず、facade deallocation後もlive context familyのassociationを失わない | +| `CodexDefaultSerialModelExecutor` | `final class: @unchecked Sendable, SerialExecutor` | callerが渡すexecutor jobだけをprivate serial queueへenqueue | private `CodexModelContext` + queueのowner。context getterをpublicにせず、jobだけが同unowned executor上でaccess | +| `FetchedResultsLoadCoordinator` | model-context isolationへcaller-confined、`Sendable`にしない | queued load Taskをcontext isolation上で生成 | intent、in-flight handle、caller continuation、atomic commitの唯一owner | +| `CodexFetchedResults` | model-context isolationへcaller-confined | Taskを作らない | descriptor/current values/full old/new snapshot transactionとbuffer-newest-1 subscribersを単独所有し、owner deinitでfinish | +| `ChatObservationOwner` | model-context isolationへcaller-confined、`Sendable`にしない | generation pumpとlease relayを生成 | pump handle、relay、upgrade/close completionを所有。`CodexChatObservation`はhandleだけを持つ | +| `ChatObservationReleaseSignal` | `Synchronization.Mutex` checked `Sendable` final class | Taskを作らない | lease IDのsync sendとsingle async receiveを線形化。generation pumpのstructured release childだけがreceiveし、owner closeでterminate + joinする | +| Testing store/transport/emitter/injector | public/package actor | transportだけがin-memory I/O childを生成 | 各actorが自身のrecord/channelをexplicit runtime/transport closeまでにfinish | +| production cancellation waiter token | `Synchronization.Mutex` stateを持つchecked `Sendable` value/class | Taskを作らない | request lane、login result、turn/review/account/connection iterator、load/observation waiterのcontinuation + pending/resumed bitだけを持ち、onCancelが同期exactly-once resume。shared operation/generationはcancelしない | +| Testing gate/deadline clock/waiter token | `Synchronization.Mutex` stateを持つchecked `Sendable` final class | Taskを作らない | pending/resumed/closedとcontinuationをmutex内に閉じ、caller cancellation/open/advance/closeのいずれかがexactly once resume | +| CRK `CodexReviewStore` / cancellation arbiter | `@MainActor` | `ReviewStoreRuntime`へworker/cancellation operationをinstall | storeがgeneration/attempt/cancellation transitionを直列化する。`stop()`だけがruntimeのasync drainをawaitし、isolated deinitはsynchronous cancel signalだけ | +| CRK `ReviewStoreRuntime` / `ReviewStoreCommitSink` | runtimeは`@MainActor` final class、sinkはweak storeを持つ`@MainActor` final class | runtimeがper-run worker Taskとaccepted product-cancellation Taskを生成 | runtimeがhandles/shared completionsを保持。Task bodyはimmutable backend/run/generationとweak sinkだけをcaptureし、commitごとに短くMainActorへhopする。store/runtimeをasync method全体でstrong retainしない | +| CRK adapter attempt registry | package actor | Taskを作らない | attempt ID→SDK `CodexReviewSession`のlive owner。interrupt/cleanup/restartが同sessionを操作しcleanupでremove | +| CRK `ReviewThreadRetentionRegistry` | package actor + crash journal | final store retirementまたはunpersisted-identity quarantine recovery時だけcaller-owned cleanup Taskを生成 | publication前pending claim→durable live-run entryを所有し、runtime-preserving stopでは保持。commit failureはsame runtime cleanup、二重failureはgates-closed quarantine。final stop/resetがrun resolver removal→matching runtime cleanup→journal removalをawaitし、startupはrecord復元がないため残存journalをorphan cleanupする | +| CRK `BackendReviewAttempt` | immutable Sendable value | Taskを作らない | required attempt + observed-terminal operation/probe + single finalizerを渡し、terminal state/queueを複製しない。completed barrier ownerはfinalizerだけ | +| CRK review worker / `ReviewWorkerState` | `@MainActor` parent + structured child task group | result operation / connectivity recovery childを生成 | parent workerがclosed `ReviewWorkerSignal`だけを裁定して全childをcancel + awaitし、generation/network/phaseを所有。custom unbounded input queue/event bridgeを持たない | +| `ReviewOutputPublicationBarrier` | model-context isolationへcaller-confined | Taskを保存せずworker structured scopeでterminal refresh/cursor waitを実行 | completed outputとprojection after-valueを一時比較し、success/failure completionだけを返す | +| MCP `MCPReviewSessionRegistry` | package actor | session close cancellation childをstructured task groupで生成 | membership、closed bit、close task/completionのowner。run cancellationはstore-owned cancellation completionをjoin | +| MCP `MCPHTTPServerLifetime` | package actor + NIO event-loop confinement | accept/cleanup timer、per-session request/heartbeat/stream Tasksを生成 | all handles、stream disconnect completion、shared stop taskを保持。callbackはactorへeventを送るだけでTaskを生成しない | +| forked MCP protocol `Server` | dependency actor | receive-loop task group内でrequest childを生成 | forked Serverがreceive loop/request children/pending continuationsを保持し、`stop()`がcancel + drain + joinする | +| Host `LoginSession` | `@MainActor` caller-confined class | root login Taskとdecision付きshared termination Taskを生成 | closingはprovisional decision + completion、closedだけがfinal resultを保持。root Task自身はterminateをawaitせずall callersはsame completionへ収束 | +| Host `LoginOperationState` | package actor | Taskを作らない | purpose、phase、borrowed/owned runtime、handle、first decision、eventual result、cleanup ownershipを所有。root/session双方がstrong retainするがsession/root Taskは互いをcaptureしない | +| Host `AccountRegistryStore` | package/private actor | Taskを作らない | on-disk mutationはcaller structured operationで実行。mutation lease、registry generation、atomic replace、revision GC、shared-auth activation completionを直列化する | +| Host `AccountRuntimeTransitionCoordinator` | `@MainActor` final class | account/login reconciliation/final-shutdown transition Taskを1つ生成 | shared completion、mutation/login lease、old HostRuntimeSession stop/new staging start、expected-account validation、final resolver/stop handoffを所有。consumer childからself stopをawaitしない | +| Host `HostRuntimeSession` | `@MainActor` final class | connection/account consumer Tasksを生成 | generation、sequence cancel handles、consumer handles、lifecycle callback installed bit、shared stop Taskを所有。consumer closureはsequence + immutable event sinkだけをcaptureしsession/backendをstrong captureしない | +| Preview `PreviewRuntimeLifetime` | `@MainActor` final class | preview stream/notification Tasksを生成 | Task handles、Testing runtime/container、stop Taskを所有。Task closureはruntime value/event sinkだけをcaptureしlifetime/storeをstrong captureしない | +| `ReviewRestartCoordinator` | package actor | restart operation Taskを必要時に1つだけ生成 | token context、shared restart completion、fixed retry budget、cleanup identitiesを保持。cleanup/runtime closeがin-flight restartをcancel + awaitしてlate sessionをcleanupする | +| `InterruptRaceResolver` | package actor-confined value | Taskを作らない | terminal-known fact、message parser、固定retry counterを1 operation scopeに閉じ、sleep/retry completionはcallerが所有する | +| presentation policies / adapters / classifiers / query plans | immutable `Sendable` valuesまたはcaller-confined stateless values | Taskを作らない | completion/stateを保持しない | + +`@unchecked Sendable` はprivate non-Sendable model contextをqueue invariantで閉じる`CodexDefaultSerialModelExecutor`と、platform process primitiveを`Mutex` stateに格納できない場合の`ProcessTerminationToken`だけに限定する。lease、login state、gate、deadline clock、waiter tokenは`Synchronization.Mutex`でchecked `Sendable`にする。model graph、observation handle、load coordinator、Host sessionにはuncheckedを付けない。`CodexModelContainer`はapp serverのchecked SendabilityとMainActor-isolated eager contextでchecked `Sendable`にする。Taskを生成するownerとhandleを保持するownerを分ける場合は表に明記された supervisor/worker 間の移譲だけとし、fire-and-forget Taskを作らない。 + +## 5. Public API sketch + +### 5.1 Turn terminal contract + +```swift +public enum CodexTurnStatus: Equatable, Sendable { + case inProgress + case completed + case interrupted + case failed + case unknown(rawValue: String) + + public init(rawValue: String) + public var rawValue: String { get } +} + +public enum CodexThreadStatus: Equatable, Sendable { + case notLoaded + case idle + case systemError + case active(activeFlags: [CodexThreadActiveFlag]) + case unknown(rawValue: String) + + public init(rawValue: String) + public var rawValue: String { get } +} + +public struct CodexTurnSnapshot: Identifiable, Equatable, Sendable { + public enum State: Equatable, Sendable { + case inProgress + case completed + case interrupted + case failed(CodexTurnError) + case unknown(rawValue: String, error: CodexTurnError?) + } + + public var id: CodexTurnID + public var state: State + public var itemsLoadState: CodexTurnItemsLoadState + public var items: [CodexThreadItem] + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? + public var status: CodexTurnStatus { get } + public var error: CodexTurnError? { get } + + public init( + id: CodexTurnID, + state: State, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexThreadItem] = [], + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) +} + +public struct CodexGenerationOptions: Equatable, Sendable { + public var model: String? + public var approvalMode: CodexApprovalMode? + public var sandbox: CodexSandbox? + public var cwd: URL? + public var effort: CodexReasoningEffort? + public var serviceTier: String? + public var summary: CodexReasoningSummary? + public var outputSchema: CodexJSONValue? + public var personality: CodexPersonality? + public var clientUserMessageID: String? + + public init( + model: String? = nil, + approvalMode: CodexApprovalMode? = nil, + sandbox: CodexSandbox? = nil, + cwd: URL? = nil, + effort: CodexReasoningEffort? = nil, + serviceTier: String? = nil, + summary: CodexReasoningSummary? = nil, + outputSchema: CodexJSONValue? = nil, + personality: CodexPersonality? = nil, + clientUserMessageID: String? = nil + ) +} + +public enum CodexTurnOutcome: Equatable, Sendable { + case completed(CodexResponse) + case interrupted(CodexResponse) + case failed(CodexFailedTurn) + case invalidTerminalStatus( + rawStatus: String, + error: CodexTurnError?, + response: CodexResponse + ) + + public var response: CodexResponse { get } +} + +public struct CodexFailedTurn: Equatable, Sendable { + public var response: CodexResponse + public var error: CodexTurnError + + package init(response: CodexResponse, error: CodexTurnError) +} + +package enum CodexTurnEvent: Equatable, Sendable { + case started(CodexTurnID) + case snapshot(CodexTurnSnapshot) + case terminal(CodexTurnOutcome) + // item/message/usage/diagnostic cases +} + +package enum CodexReviewEvent: Equatable, Sendable { + case turnStarted(CodexTurnID) + case snapshot(CodexTurnSnapshot) + case terminal(CodexTurnOutcome) + // item/message/usage/diagnostic cases +} + +package enum CodexReviewProgress: Equatable, Sendable { + case running( + transcript: CodexTranscript, + usage: CodexTokenUsage? + ) + case terminal(CodexTurnOutcome) +} + +package struct CodexResponseStream: AsyncSequence, Sendable { + package typealias Element = CodexTurnEvent + package func collect(timeout: Duration? = nil) async throws -> CodexTurnOutcome + package func cancel() async throws -> CodexTurnCancellation + package func closeConnection() async +} + +public struct CodexThread: Identifiable, Sendable { + // Options/ResumeOptions and id/workspace/model remain baseline-public. + public func respond( + to prompt: CodexPrompt, + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil + ) async throws -> CodexTurnOutcome + public func respond( + to prompt: String, + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil + ) async throws -> CodexTurnOutcome + public func respond( + options: CodexGenerationOptions = .init(), + timeout: Duration? = nil, + @CodexPromptBuilder prompt: () throws -> CodexPrompt + ) async throws -> CodexTurnOutcome + public func startReview( + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline + ) async throws -> CodexReviewSession + public func cancelActiveTurn( + expectedTurnID: CodexTurnID? = nil + ) async throws -> CodexTurnCancellation + public func read(includeTurns: Bool = false) async throws -> CodexThreadSnapshot + public func listTurns(_ query: CodexTurnQuery = .init()) async throws -> CodexTurnPage + public func rename(to name: String) async throws + public func compact() async throws + public func archive() async throws + public func unarchive() async throws -> CodexThreadSnapshot + public func delete() async throws + public func closeConnection() async + + package func rollback(turnCount: Int = 1) async throws +} + +public struct CodexReviewSession: Identifiable, Sendable { + public var id: CodexTurnID { get } + public let threadID: CodexThreadID + public let turnID: CodexTurnID + public let reviewThreadID: CodexThreadID + public let model: String? + public let initialTurn: CodexTurnSnapshot + public var identity: CodexReviewIdentity { get } + public var sourceThreadID: CodexThreadID { get } + public var activeTurnThreadID: CodexThreadID { get } + public var associatedThreadIDs: [CodexThreadID] { get } + public var cleanupThreadIDs: [CodexThreadID] { get } + + public func collect(timeout: Duration? = nil) async throws -> CodexTurnOutcome + public func terminalOutcomeIfKnown() async throws -> CodexTurnOutcome? + public func cancel() async throws -> CodexTurnCancellation + public func closeConnection() async +} + +public enum CodexErrorInfo: Equatable, Sendable { + case contextWindowExceeded + case sessionBudgetExceeded + case usageLimitExceeded + case serverOverloaded + case cyberPolicy + case httpConnectionFailed(httpStatusCode: UInt16?) + case responseStreamConnectionFailed(httpStatusCode: UInt16?) + case internalServerError + case unauthorized + case badRequest + case threadRollbackFailed + case sandboxError + case responseStreamDisconnected(httpStatusCode: UInt16?) + case responseTooManyFailedAttempts(httpStatusCode: UInt16?) + case activeTurnNotSteerable(turnKind: String) + case other + case unknown(rawValue: String) +} + +public struct CodexTurnError: Error, Equatable, LocalizedError, Sendable { + public var message: String + public var info: CodexErrorInfo? + public var additionalDetails: String? + + public init( + message: String, + info: CodexErrorInfo? = nil, + additionalDetails: String? = nil + ) +} + +public struct CodexResponse: Identifiable, Equatable, Sendable { + public var turnID: CodexTurnID + public var transcript: CodexTranscript + public var usage: CodexTokenUsage? + public var startedAt: Date? + public var completedAt: Date? + public var duration: Duration? + public var id: CodexTurnID { get } + + public init( + turnID: CodexTurnID, + transcript: CodexTranscript = .init(), + usage: CodexTokenUsage? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + duration: Duration? = nil + ) +} +``` + +Rules: + +- server-side interrupt is `.interrupted`; terminal failed is `.failed`; caller Task cancellation throws `CancellationError`. +- `CodexResponseStream.collect()` / iterator の Task cancellation と early break は local consumption だけを止め、server turn を暗黙に interrupt しない。server-side stop は `cancel()` だけが所有する。 +- handle を返さない `CodexThread.respond(...)` convenience は開始した turn の completion owner なので、caller cancellation / overall timeout 時に turn interrupt とその acknowledgement を完了してから throw する。 +- current-v2 status decoderが認識するraw valueは `completed` / `interrupted` / `failed` / `inProgress` だけとする。`success` / `succeeded` / `cancelled` / `aborted` / `started` 等のhistorical aliasは正規化せず `.unknown(rawValue)` として保持し、terminal位置では `.invalidTerminalStatus` にする。 +- `CodexTurnStatus.inProgress.rawValue` は`"inProgress"`である。`CodexThreadStatus`も`notLoaded/idle/systemError/active`だけを既知としてdecodeし、historical `closed`を`.notLoaded`へaliasしない。 +- terminal notification の `.inProgress` / unknown status は `.invalidTerminalStatus`。required status欠損やturn payload decode失敗は `CodexAppServerError.malformedNotification` であり、空の `CodexResponse` をfabricateしない。 +- raw statusが`failed`なのにrequired `Turn.error` が欠けるpayloadもmalformed notificationであり、`.failed`を生成しない。`CodexFailedTurn.error` がclassifier boundaryでnon-optionalになるためconsumer guardは不要である。 +- raw statusがknown `inProgress/completed/interrupted`なのに`Turn.error`がnonnilのpayloadもillegal current-v2 productとしてmalformed notificationにし、errorをsilent dropしない。future `.unknown(rawValue:error:)`だけはunknown statusに付随するoptional errorを保持し、terminal classifierの`.invalidTerminalStatus(rawStatus:error:response:)`までlosslessに渡す。 +- pinned current-v2 `Turn` のrequired `status`はdecoder boundaryで必ず`CodexTurnSnapshot.State`へ変換する。`failed`だけがassociated `CodexTurnError`を要求し、`inProgress/completed/interrupted`はerrorを保持できない。future raw statusだけはforward-compatibleな`.unknown(rawValue:error:)`へlosslessに投影する。`startedAt/completedAt/durationMs`はそれぞれ`Date?/Date?/Duration?`へ変換し、response timingと同じ値を保持する。production snapshotはwire DTOそのものではなく、この合法状態だけを公開するdomain projectionである。 +- transport EOF/connection close before terminal is `CodexAppServerError.connectionTerminated`, not an outcome and not synthetic success/failure。 +- response/session handle の `collect(timeout:)` は `.turnDeadlineExceeded(turnID:duration:)` をthrowしてhandleを維持し、callerが`cancel()`を選べる。handleを返さない`respond(timeout:)`は上記cleanupを完了する。 +- `CodexTurnCancellation` はinterrupt controlが対象にしたthread/turnのcorrelation valueで、terminal outcomeが`.interrupted`だった証明ではない。generation stateが既にterminalならsession `cancel()`はwire requestを送らず同target valueを返し、cached outcomeを変更しない。product側は先にcommitしたpending cancellationとcached backend outcomeをCRK arbiterで裁定する。generic `CodexThread.cancelActiveTurn`はgeneration stateを持たないためterminal-known shortcutを使わない。 +- `CodexTurnStatus.isFailure`、current `turnFailed` event、`.closed/.notLoaded` outcome synthesis を削除する。 +- `CodexChat` はgeneric load phaseからcompactな `CodexChatPhase` へ分ける。terminal response/transcriptはturn/itemsだけが所有し、phaseはturn IDとclassifierが確定したdispositionだけを保持する。 +- upstream `TurnError.message/codexErrorInfo/additionalDetails` は `CodexTurnError` にlossless decodeする。`CodexResponse.errorMessage` は削除し、failed outcomeだけが `CodexFailedTurn.error` をnon-optionalで公開する。表示文はそのtyped errorからderiveする。既知のJSON-RPC error `data` 内に同じshapeがある場合も同じdecoderを使い、`CodexServerError.data` のraw bytesも保持する。 +- strict-status testsは分ける: historical alias、required status欠損、terminal `inProgress`、failed+missing error、known nonfailed+nonnil error、future unknown+error preservation、turn payload malformed。malformed caseがoutcome streamへ到達しないことも検証する。 + +### 5.2 Error and deadline contract + +```swift +public enum CodexAppServerError: Error, Equatable, LocalizedError, Sendable { + case launch(CodexLaunchFailure) + case request(CodexRequestFailure) + case connectionTerminated(CodexConnectionTermination) + case turnDeadlineExceeded(turnID: CodexTurnID, duration: Duration) + case malformedNotification(CodexMalformedNotification) + case reviewRestartUnavailable(CodexReviewRestartToken.ID) + case loginAlreadyInProgress +} + +public struct CodexRequestFailure: Error, Equatable, LocalizedError, Sendable { + public var requestID: Int + public var method: String + public var purpose: CodexRequestPurpose + public var kind: Kind + + public enum Kind: Equatable, Sendable { + case encode(message: String) + case write(CodexTransportFailure) + case transport(CodexTransportFailure) + case server(CodexServerError) + case invalidResponse(expectedType: String, message: String, rawData: Data?) + case deadlineExceeded(Duration) + case overloadRetryExhausted(last: CodexServerError, attempts: Int) + } +} + +public enum CodexRequestPurpose: Equatable, Sendable { + case handshake + case operation(String) +} + +public struct CodexServerError: Error, Equatable, LocalizedError, Sendable { + public var code: Int + public var message: String + public var data: Data? + public var turnError: CodexTurnError? + + public init( + code: Int, + message: String, + data: Data? = nil, + turnError: CodexTurnError? = nil + ) +} + +public enum CodexTransportFailure: Error, Equatable, LocalizedError, Sendable { + case closed + case io(errno: Int32?, message: String) + case framing(message: String, rawData: Data?) + case protocolViolation(message: String, rawData: Data?) + case contractViolation(message: String) +} + +public enum CodexLaunchFailure: Error, Equatable, LocalizedError, Sendable { + case executableNotFound(command: String, searchedPath: String?) + case scaffold(path: String, message: String) + case spawn(executable: String, errno: Int32?, message: String) +} + +public struct CodexMalformedNotification: Error, Equatable, LocalizedError, Sendable { + public var method: String + public var message: String + public var rawData: Data? +} + +package struct CodexAppServerClock: Sendable { + package var now: @Sendable () -> Date + + package init(now: @escaping @Sendable () -> Date = { Date() }) +} + +package struct CodexDeadlineClock: Sendable { + package var sleep: @Sendable (Duration) async throws -> Void + + package static var continuous: Self { get } +} + +public struct CodexReviewCleanupFailure: Equatable, Sendable { + public var threadID: CodexThreadID + public var message: String + + public init(threadID: CodexThreadID, message: String) +} + +public struct CodexReviewCleanupResult: Equatable, Sendable { + public var attemptedThreadIDs: [CodexThreadID] + public var failures: [CodexReviewCleanupFailure] + public var succeeded: Bool { get } + + public init( + attemptedThreadIDs: [CodexThreadID], + failures: [CodexReviewCleanupFailure] + ) +} + +public actor CodexAppServer { + public struct Configuration: Sendable { + public struct Deadlines: Equatable, Sendable { + public var handshake: Duration? + public var request: Duration? + + public init( + handshake: Duration? = nil, + request: Duration? = nil + ) + } + + public var localProcess: LocalProcess + public var clientName: String + public var clientVersion: String + public var deadlines: Deadlines + package var clock: CodexAppServerClock + package var serverRequestHandler: CodexAppServerRequestHandler? + + public init( + localProcess: LocalProcess = .init(), + clientName: String = "CodexAppServerKit", + clientVersion: String = "1", + deadlines: Deadlines = .init() + ) + + package init( + localProcess: LocalProcess = .init(), + clientName: String = "CodexAppServerKit", + clientVersion: String = "1", + deadlines: Deadlines = .init(), + clock: CodexAppServerClock = .init(), + serverRequestHandler: CodexAppServerRequestHandler? + ) + } + + public init(configuration: Configuration = .init()) async throws + public func startThread( + in workspace: URL, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init() + ) async throws -> CodexThread + public func startReview( + in workspace: URL, + target: CodexReviewTarget, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init(), + delivery: CodexReviewDelivery = .inline + ) async throws -> CodexReviewSession + public func resumeThread( + _ id: CodexThreadID, + options: CodexThread.ResumeOptions = .init() + ) async throws -> CodexThread + public func resumeReview( + _ identity: CodexReviewIdentity, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewSession + public func prepareReviewRestart( + _ identity: CodexReviewIdentity, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewRestartToken + public func restartPreparedReview( + _ token: CodexReviewRestartToken, + target: CodexReviewTarget, + delivery: CodexReviewDelivery = .inline, + threadOptions: CodexThread.ResumeOptions = .init() + ) async throws -> CodexReviewSession + public func discardPreparedReviewRestart( + _ token: CodexReviewRestartToken + ) async -> [CodexReviewIdentity] + public func discardAllPreparedReviewRestarts() + async -> [CodexThreadID: [CodexReviewIdentity]] + @discardableResult + public func cleanupReview( + _ identity: CodexReviewIdentity, + additionalCleanupThreadIDs: [[CodexThreadID]] = [] + ) async -> CodexReviewCleanupResult + public func forkThread( + _ id: CodexThreadID, + options: CodexThread.Options = .init() + ) async throws -> CodexThread + public func unarchiveThread(_ id: CodexThreadID) async throws -> CodexThread + public func archiveThread(_ id: CodexThreadID) async throws + public func deleteThread(_ id: CodexThreadID) async throws + public func listThreads( + _ query: CodexThreadQuery = .init() + ) async throws -> CodexThreadPage + public func models(includeHidden: Bool = false) async throws -> [CodexModel] + public func account(refreshToken: Bool = false) async throws -> CodexAccount? + public func configuration() async throws -> CodexConfiguration + public func updateConfiguration(_ patch: CodexConfigurationPatch) async throws + public func rateLimits() async throws -> CodexRateLimits + public func logout() async throws +} +``` + +`cleanupReview` は review lifecycle cleanup の唯一の public authority である。attempt順・source-last順と各delete failureをtyped resultで返し、failureが1件でもある場合はrestart coordinatorのretained identityを保持してdurable callerの再試行を可能にする。resultを使わないbest-effort callerは`@discardableResult`で同じcontractを呼ぶ。failureを捨ててretained identityも破棄するvoid overloadや、別名の`cleanupReviewReportingFailures`は置かない。 + +`prepareReviewRestart` / `restartPreparedReview` / `discardPreparedReviewRestart` / `discardAllPreparedReviewRestarts`は`ReviewRestartCoordinator`へ委譲し、tokenごとのstateを次に固定する。後二者は別packageのCRK adapterとHost runtimeがinvalidation完了とretained identity移譲をawaitするためのpublic close authorityであり、coordinator stateやHost固有型は公開しない。 + +```swift +package actor ReviewRestartCoordinator { + package struct Context: Sendable { + package let token: CodexReviewRestartToken + package let interruptedIdentity: CodexReviewIdentity + package let rollbackThreadID: CodexThreadID + package var rollbackCompleted: Bool + package var restartAttemptsUsed: Int + } + + package enum State: Sendable { + case preparing( + Context, + completion: Task, + invalidationRequested: Bool + ) + case prepared(Context) + case restarting( + Context, + signature: RestartInvocationSignature, + completion: Task, + invalidationRequested: Bool + ) + case invalidating( + Context, + completion: Task + ) + } + + package func invalidate( + _ token: CodexReviewRestartToken + ) async -> [CodexReviewIdentity] + package func invalidateAllAndWait() + async -> [CodexThreadID: [CodexReviewIdentity]] +} +``` + +`prepareReviewRestart`は検証/登録だけではない。active reviewをresume→interruptし、typed interrupted terminal acknowledgementまでawaitしてcleanup identitiesをsource-keyed ordered registryへretainした後にだけtokenを返す。`.preparing` shared completionがこのentire operationを所有し、concurrent prepareはrejectする。caller cancellation/cleanupはinvalidation bitを先にlinearizeしcompletionをcancel + awaitするため、外へ返していないtoken/sessionを残さない。intentional interrupted terminalはcoordinator acknowledgementでありCRK product terminalへ再送しない。 + +`restartPreparedReview`はfull token ID + interrupted identityを検証し、`.prepared`だけがattempt budgetを1消費して`.restarting`へ進む。`rollbackCompleted == false`ならdeprecated `thread/rollback(turnCount: 1)`をexactly once成功させ、flagを**次のawaitより前**にactor stateへcommitしてからsource resume/startへ進む。coordinatorはdelay/automatic retryをしない。最大2 restart invocationのうちCRK recoveryは1回だけを使い、2回目はexplicit public caller retry専用である。 + +同じtokenへのconcurrent restartはtarget/delivery/optionsから作る`RestartInvocationSignature`が同一なら`.restarting`のshared completionへjoinし、異なればbusy/unavailableをthrowする。waiter Task cancellationはwaiterだけを外しshared operationを止めない。successはnew review identityをsource-keyed retained cleanup listへ追加し、token entryを同じactor turnでremoveしてconceptual consumedにする。operation failure/cancellationがlate `CodexReviewSession`を得た場合はそのsessionをinterrupt/closeしてidentityをretained listへ追加してからprepared/invalidated completionを返す。 + +failure dispositionは次で固定する。 + +| Failure point | Token disposition | +|---|---| +| restart claim前のcaller cancellation | `.prepared`維持、budget未消費 | +| rollbackの明示server rejection | budget残ありなら`.prepared(rollbackCompleted:false)` | +| rollback write/responseが不明なconnection termination / invalid response | token invalid化。二重rollbackしない | +| rollback成功後のsource resume failure | budget残ありなら`.prepared(rollbackCompleted:true)` | +| `review/start` pre-write failure / 明示server rejection | budget残ありなら`.prepared(rollbackCompleted:true)` | +| `review/start` post-write response loss / invalid identity response | token invalid化。duplicate reviewを推測して再startしない | +| session取得後のcaller/cleanup cancellation | late session interrupt/release/identity retain後にinvalid化 | +| budget exhaustion | invalid化 + `.reviewRestartUnavailable` | + +final run retirement、CRK cancellation、Host runtime closeはtoken invalidation authorityである。invalidationが`.preparing/.restarting`と競合した場合は`invalidationRequested`を線形化してnew callerを拒否し、`.invalidating(shared completion)`でoperationをcancel + awaitし、late replacement sessionをidentity listへ取り込んでからreturnする。restart completionがinvalidation後にsessionをpublishする経路はない。CRK cancellationとpreserving runtime closeは返されたidentitiesをsame in-memory runの`ReviewThreadRetentionRegistry` journalへmergeし、threadをdeleteしない。durable ownership + run publicationを終えたidentityはtop-level final store stop/resetだけが全runをresolver/UIからatomic retireした後に`cleanupReview`へ渡す。publication前journal failureのquarantine cleanupはこのlifetime policyの例外で、visible runを削除するのではなくownership確立不能なupstream artifactを即時rollbackする。consumed/invalidatedはconceptual terminalで、identitiesをcaller/retentionへ移した同じactor turnにtoken mapからentryを削除しcoordinator内tombstoneを蓄積しない。missing/consumed/invalidated/budget exhaustedは同じtyped unavailableで、別source tokenへfallbackしない。runtime closeは`invalidateAllAndWait()`→retention mergeをadapter registry releaseより先に完了する。 + +Request ID は encode より先に採番するため、encode/write/server/decode/timeout/retry exhaustion のすべてが `requestID`、`method`、`purpose` を持つ。server busy と retry exhaustion のtop-level caseは置かない。transport readのterminal failureは接続全体の `connectionTerminated`、特定requestのencode/write/decode/server failureは `request`、turnを待つ期限だけは `turnDeadlineExceeded` がownerである。 + +`.write` はencode済みoutbound envelopeのI/O write失敗、`.transport(.contractViolation)` はsend seamがresponseを開始する前に同期的に拒否したcontract failure(strict test fake等)だけに使う。production inbound framing/protocol/EOF failureはconnection-wide terminationであり、個別requestへ二重包装しない。 + +`CodexAppServerClock` は `currentTime/read` response用wall clockだけである。handshake/request/turn deadlineはpackage `CodexDeadlineClock.sleep` だけを使い、live compositionは `ContinuousClock`、testsはmanual monotonic clockを注入する。wall clockのadvanceや`Task.sleep`でdeadlineを試験しない。 + +`deadlines.handshake` が設定されている場合は initialize request送信から initialized notification write完了までの全handshakeを囲み、generic request deadlineより優先する。nilなら `deadlines.request` をinitializeにも適用する。どちらの場合もinitialize request IDをcorrelation IDとし、failureは `purpose: .handshake` で返す。default deadline は `nil` とし、inter-event-gap timeout は設けない。`CancellationError` はどの層でも包まず、retry queueからも除去する。 + +caller cancellationはrequest write acceptanceをcommit pointに分ける。queued lane waiterまたはtransportがrequestを受理する前ならoperationを除去してlaneをreleaseし、wire effectなしで`CancellationError`を返す。write acceptance後はlocal `RequestOperationState`へcancel bitだけを立て、caller-owned cancellation-shielded operation Taskがcorrelated response/connection terminalを観測するまでlaneを保持する。通常requestはresponseをdecodeしてshared stateをcommitせず`CancellationError`を返す。handle-producing/mutating operationは次のcleanup matrixを同じscoped lane token内で完遂してからthrowする。local Task handleは必ずcaller scopeがawaitし、client/serializerへ保存もdetachもしない。 + +| Post-write cancelled operation | Required completion before `CancellationError` | +|---|---| +| standalone `startThread` | returned thread IDをbindし、同lane tokenでdelete acknowledgementまでawait | +| `forkThread` | returned fork IDをbindし、delete acknowledgementまでawait | +| workspace/thread `startReview` | review response identity/sessionをbindし、interrupt→terminal acknowledgementをawait。workspace convenienceはcreated source+detached threadをcleanup、existing `CodexThread` methodはdetached threadだけcleanupしsourceを残す。review request前にcancelならworkspace convenienceの作成済みsourceだけdelete | +| `loginChatGPT` | start response ID/URLを`LoginHandleState`へbindし、URLを開かずcancel winner→connection lease releaseまでawait | +| resume/read/list/config/account等 | correlated response/terminalまでlaneを保持し、resultをcallerへ返さずthrow。server effectを推測でrollbackしない | + +same-scope次operationは上表completionより先にlaneへ入れない。post-write response自体がconnection terminalで失われた場合はfull connection closeがserver-side orphan cleanup authorityとなり、そのcompletion後に`CancellationError`ではなくfirst terminal `CodexAppServerError.connectionTerminated`を返す。 + +### 5.3 Internal transport contract + +```swift +package enum CodexServerRequestID: Hashable, Sendable, Codable { + case integer(Int64) + case string(String) +} + +package protocol JSONRPCTransport: Sendable { + func send(_ request: JSONRPC.RequestEnvelope) async throws + -> JSONRPC.ResponseEnvelope + func notify(_ notification: JSONRPC.NotificationEnvelope) async throws + func nextInboundEvent() async throws -> JSONRPC.InboundEvent? + func respond( + to requestID: CodexServerRequestID, + with response: JSONRPC.ServerResponse + ) async throws + func beginClose() async + func finishPendingResponsesAfterInboundDrain( + _ failure: CodexTransportFailure + ) async + func waitForProcessExit() async -> Int32? + func waitUntilClosed() async + func reapProcess() async +} + +package extension JSONRPC { + enum InboundEvent: Sendable { + case notification(NotificationEnvelope) + case serverRequest( + id: CodexServerRequestID, + method: String, + params: Data + ) + } +} +``` + +process transport は framing、I/O、stdout/stderr reader、pending response continuation、process signal/wait/reap だけを所有し、notification decode、server-request handler、in-flight registryを所有しない。`ConnectionSupervisor` は `AppServerConnection.runInboundEvents()` を実行する唯一のrouter Taskを生成・保持し、connectionは `ServerRequestRegistry` referenceを保持するがhandler Task handleは持たない。registryだけがserver-request handler Tasks/responderを生成・保持する。test transportも同じprotocolを実装するため、fake request injectionだけがdomain handlerを直呼びする経路は存在しない。 + +`AsyncThrowingStream` の暗黙unbounded bufferはtransport seamに使わない。live stdout readerとtest injectorはresponse / notification / server requestを区別しないraw inbound frameとして同じcapacity 16のactor mailboxへ`await send`し、reader自身はrequest continuationをresumeしない。live readerはfd readinessごとに最大64 KiBのraw chunkだけをproducer Task内へread-aheadし、そのchunkをline parserへ逐次渡して各frameの`send` completionをawaitする。未transfer bytesが残る間はfdから次のchunkを読まない。routerだけがsingle-consumer `nextInboundEvent()`でwire orderどおりdrainする。response frameならtransport-owned pending continuationをその場でresumeしてloopし、notification/server requestだけをconnectionへ返す。connectionは返されたeventのdecoder/reducer/registry処理を完了してから次の`nextInboundEvent()`を呼ぶため、wire上notification→responseの順ならnotification state commitがcancel/start response waiterより必ず先になる。early start event、login completion vs cancel response、test fakeもこのsingle causal laneを通る。 + +mailbox fullではproducerをsuspendしてlive pipe/kernel bufferまでbackpressureし、notification/item delta/server request/responseをdropしない。transport-owned capacityはready buffer 16 + **global accepted overflow slot 1**の最大17 frameで、producer数に比例させない。`send(frame)`はまずpayloadを渡さないcancel-aware admission tokenを取得する。readyに空きがあればそのactor turnでframeをaccept、ready fullでもglobal overflowが空なら1 frameだけをacceptしてtransport ownershipへ移す。両方fullならcaller Taskがframeを所有したままadmission waiter tokenだけを登録し、slotが空くまでtransferはまだ線形化しない。closeはunaccepted waiterをtyped closedでresumeするため、それらのframeはtransport drain対象ではない。 + +open中にglobal overflowへaccept済みの1 frameはcloseと競合してもreject/dropせずreadyへpromoteし、routerがdrainしてsend completionをsuccessにする。closingへlinearizeした後のnew transferだけをrejectする。slot promotion時はFIFO waiterを1つだけwakeし、そのcallerがopenを再確認してframeをtransferする。waiter cancellationはtokenを同期removeしframe ownershipをcallerへ保つ。test concurrent producersは各send completionをawaitする。live readerがmailbox fullでsuspendした場合は、その時点で既にread済みの同一raw chunk remainderだけをproducer Taskが保持し、新しいfd readを行わない。closeはmailboxへtransfer済みの最大17 frameだけをlosslessにdrainし、close linearization後も未transferのraw chunk remainderはproducer-owned inputとしてdropできる。transport mailboxが保持するpayload数は常に17以下であり、transport全体のbounded input memoryはこの17 frame、最大16 MiBのin-progress line、最大64 KiBのraw read-ahead chunkから増えない。 + +JSON line parserは1 frame 16 MiBを上限とし、超過はraw bytesを保持しないtyped framing failureでconnectionを終了する。EOF/explicit close/failureはnon-droppable terminal stateとしてnew sendを拒否するが、buffer済みframeとaccepted-sender slots、それらに対応するrequest continuationは先にrouterがdrainする。`nextInboundEvent()` はresponseをwire orderでresumeしながらdomain eventだけを返し、全accepted frame後にnil/terminalを返す。その後supervisorだけが`finishPendingResponsesAfterInboundDrain`を1回呼び、まだ未解決のresponse continuationをtyped failureでresumeする。preconditionはmailbox terminal observed + buffer/accepted slots emptyで、早いcallはcontract violationである。二つ目のconcurrent `nextInboundEvent()` はcontract violationにする。test emitter/injector/queued responseも同じasync mailbox send completionをawaitするためlive/testで順序とbackpressureが一致する。 + +live stdout/stderrは`O_NONBLOCK` fdを`DispatchSourceRead`のreadinessで駆動し、polling sleepやFoundation `FileHandle.bytes`のshared async queueへ依存しない。sourceの`cancel()`は非同期なので、fdをevent handlerと競合してcloseしない。source cancel handlerだけが対応fdをcloseしてcancellation completionをsignalし、reader/drain Taskはそのcompletionをcancellation-shieldedにawaitしてから完了する。`waitUntilClosed()`は両cancel handler完了後にだけreturnする。`read` / `waitid` / final `waitpid`の`EINTR`は同じowner内でretryし、readinessやreapを偽のterminal failureへ変換しない。process exitは`DispatchSourceProcess(.exit)`でwakeし、`waitid(WNOWAIT)`のcompact observationを保存してからtransport ownerが`waitpid`をexactly once実行する。 + +raw response envelopeはinteger client request IDと、`result` / `error`のexactly oneを要求する。`error`はinteger `code`とString `message`をrequiredとし、欠損や型違いをfallback値へ補完しない。JSON `null`は`{}`へ変換せずraw `null`として保持する。booleanをFoundation bridgeでinteger ID/codeとして受理せず、malformed envelopeはraw frame付き`CodexTransportFailure.protocolViolation`でconnectionを終了する。 + +outbound client request IDはSDKが採番する `Int` のまま、inbound server-request IDはJSON-RPC contractどおりinteger/stringをlosslessに扱う `CodexServerRequestID` とする。transport envelope、registry key、responder、resolved notification、test injectorの全経路で同じ型を使い、string IDを整数へ変換しない。 + +close order は `ConnectionSupervisor` だけが実行し、new outbound/handler work拒否 → registry/controlへclosing signal → `beginClose()`(mailboxをclosingにしてbuffer済み/accepted frameは保持)→ routerをnormal decodeまたはresponses-only drain modeでtransport terminalまでawait → first terminal causeに対応するfailureで`finishPendingResponsesAfterInboundDrain` → process-exit以外のcontrol Tasks cancel + await → `ServerRequestRegistry.cancelAllAndWait()` → router/reducer/handler quiescence確認後にdomain streamsをconnection terminationでfinish → `waitUntilClosed()` → process-exit child join → `reapProcess()` → supervisor/resource stateをclosedへcommit、で固定する。buffer済みserver requestはclosing registryがhandlerをspawnせずdrop + diagnosticとし、writerを再開してresponseしない。supervisorは外部lease objectをretainしないためclose sequenceがleaseをreleaseするとは書かず、generation stateはtyped termination transitionで自分のleaseをnil化し、root/thread handleのclosed leaseは最後のcopy dropまで無害に残る。domain terminal publish後にinbound reducerが走る経路はない。外部callerからどのphaseでreentrant `closeConnection()`してもsupervisorの同じshared completionをawaitする。childは`recordExitSignal`だけをawaitしてreturn/drainし、shared close completionをawaitしない。 + +router/control Task自身はfull closeを呼ばない。EOF、router infrastructure failure、process exitはtyped `ConnectionExitSignal` を`ConnectionSupervisor.recordExitSignal`へ渡し、supervisorだけが上記close Taskを開始する。decoder/reducer failure時のrouterはsignal後に**responses-only drain mode**へ移り、response frameはtransportがwire orderでcontinuationへ届け、notification/server requestはmethod/request IDだけdiagnosticしてdomainへ適用せず、mailbox terminalまで`nextInboundEvent()`をsingle-consumeしてからreturnする。これによりmalformed eventの後ろにbufferされたresponseも失わない。request-local configured handler throwは`ServerRequestRegistry`がそのrequestへ`-32603`をexactly once返してdiagnosticをemitするだけで、connection exit signalにはしない。handlerがcaptured handleから `closeConnection()` を呼ぶ場合はTask-local owned-contextを検出し、supervisorへclose signalを送った時点でそのchild callだけをreturnさせる(typed diagnosticをemitする)。child自身がshared completionをawaitすることは禁止し、return後にregistry/supervisorがそのchildをcancel + awaitしてfull completionを閉じる。通常のconsumer/root/handle callerは従来どおりshared completionをawaitする。initializeはfactoryのstructured scopeで完了させ、成功後にだけfacade/first leaseを公開するため、shared initialize Task handleは保持しない。 + +live transportのprocess waiterはexit statusをcompact cacheし、supervisor-owned process-exit childだけが`waitForProcessExit()`をawaitする。unexpected exitは`.processExited(status:)`をfirst-terminal arbitrationへclaimし、explicit close/transport failure後のexitはlate diagnosticにする。in-memory transportはclose terminal後にnilを返し、nilはprocess-exit claimを作らない。 + +`beginClose()` はlive transportでstdin writerをcloseし、process-group terminate-once signalを同期開始してmailboxをclosingへ遷移するが、buffer済みframeより先にclosed terminalをdeliveryしない。test transportも同phaseでin-memory mailboxをclosingにし、buffer drain後にterminalを出す。spawn後のhandshake/initialize/factory failureではfacadeが未公開でもfactoryがsupervisorのfull-close completion(reader/drain/handler cleanupとprocess reapを含む)をawaitしてからtyped launch/request failureをthrowし、partial processを残さない。 + +`AppServerNotificationDecoder`はpinned SHA `8347b8d…` の`server_notification_definitions!`を次の閉じたdisposition tableで網羅する。同じrowに列挙したmethodもswitchでは個別caseとcanonical DTO decoderを持つ。known methodのpayload decode failureはすべて`CodexAppServerError.malformedNotification`でconnection exitをclaimし、正常値やempty itemへ補完しない。unknown future methodだけをbounded `CodexConnectionEvent.unknown`へ送る。 + +| Pinned current-v2 notification method | Disposition / owner | +|---|---| +| `error` | `CodexItemReducer`のturn diagnosticへroute。`willRetry`を保持し、trueはretrying表示、falseも`turn/completed`より先にterminalをfabricateしない | +| `thread/started`, `thread/status/changed`, `thread/archived`, `thread/deleted`, `thread/unarchived`, `thread/name/updated`, `thread/tokenUsage/updated` | thread/account model reducerへroute | +| `thread/closed` | thread lifecycle reducerへrouteしloaded/subscription stateだけを閉じる。turn completed/failed outcomeを合成しない | +| `turn/started`, `turn/completed`, `turn/diff/updated`, `turn/plan/updated` | `TurnReplayStore` / `CodexItemReducer`へroute。terminalは`turn/completed`だけがclassifierへ進む | +| `item/started`, `item/completed`, `item/agentMessage/delta`, `item/plan/delta`, `item/reasoning/summaryTextDelta`, `item/reasoning/summaryPartAdded`, `item/reasoning/textDelta`, `item/commandExecution/outputDelta`, `item/fileChange/patchUpdated`, `item/mcpToolCall/progress` | current-v2 item reducerへroute | +| `serverRequest/resolved` | `ServerRequestRegistry`へrouteしmatching handlerをcancel + awaitしてresponse writeを抑止 | +| `account/updated`, `account/rateLimits/updated` | 前者はpost-successならまず`LoginRegistry`で`authMode == .chatGPT` readinessをarbitrateし、その同じcausal transactionから`AccountEventHub`へinvalidationをroute。後者は`AccountEventHub`でsparse merge | +| `account/login/completed` | `LoginRegistry`へID-correlated routeしbroad account streamへ流さない | +| `warning`, `guardianWarning`, `deprecationNotice`, `configWarning` | typed/bounded connection diagnosticへroute | +| `skills/changed`, `thread/goal/updated`, `thread/goal/cleared`, `thread/settings/updated` | canonical payloadを検証後、現consumerがないためexplicit ignore + bounded debug counter | +| `hook/started`, `hook/completed`, `item/autoApprovalReview/started`, `item/autoApprovalReview/completed`, `rawResponseItem/completed` | canonical payloadを検証後explicit ignore。item/turn lifecycleへ代替eventをfabricateしない | +| `command/exec/outputDelta`, `process/outputDelta`, `process/exited`, `item/commandExecution/terminalInteraction`, deprecated `item/fileChange/outputDelta` | canonical payloadを検証後explicit ignore。SDKが開始していないstandalone process/legacy streamをdomain itemへ混ぜない | +| `mcpServer/oauthLogin/completed`, `mcpServer/startupStatus/updated`, `app/list/updated`, `remoteControl/status/changed` | canonical payloadを検証後explicit ignore。MCP review session lifecycleとは別protocol | +| `externalAgentConfig/import/progress`, `externalAgentConfig/import/completed`, `fs/changed` | canonical payloadを検証後explicit ignore | +| deprecated `thread/compacted` | canonical payloadを検証後explicit ignore。current-v2 `ContextCompaction` itemだけをdomain route | +| `model/rerouted`, `model/verification`, `turn/moderationMetadata`, `model/safetyBuffering/updated` | canonical payloadを検証後connection diagnosticへrouteし、model/turn stateを推測で書き換えない | +| `fuzzyFileSearch/sessionUpdated`, `fuzzyFileSearch/sessionCompleted` | canonical payloadを検証後explicit ignore | +| `thread/realtime/started`, `thread/realtime/itemAdded`, `thread/realtime/transcript/delta`, `thread/realtime/transcript/done`, `thread/realtime/outputAudio/delta`, `thread/realtime/sdp`, `thread/realtime/error`, `thread/realtime/closed` | canonical payloadを検証後explicit ignore。realtime APIはscope外 | +| `windows/worldWritableWarning`, `windowsSandbox/setupCompleted` | canonical payloadを検証後connection diagnosticへroute | + +pin更新時はmacro inventoryからmethod setを生成するpackage contract testでtable/switchの追加漏れをfailさせる。explicit-ignore caseもpayload fixtureを1つ持ち、単なるdefault branchへ落とさない。 + +### 5.4 Connection lifecycle and diagnostics + +```swift +public enum CodexConnectionEvent: Equatable, Sendable { + case warning(CodexDiagnostic) + case retrying(CodexRetryDiagnostic) + case deprecation(CodexDeprecationNotice) + case unknown(CodexRawNotification) + case terminated(CodexConnectionTermination) +} + +public enum CodexConnectionTermination: Equatable, Sendable { + case closedByCaller + case transportFailure(CodexTransportFailure) + case processExited(status: Int32?) +} + +public struct CodexDiagnostic: Equatable, Sendable { + public var message: String { get } + public var method: String? { get } + public var details: String? { get } +} + +public struct CodexRetryDiagnostic: Equatable, Sendable { + public var requestID: Int { get } + public var method: String { get } + public var attempt: Int { get } + public var delay: Duration { get } + public var serverError: CodexServerError { get } +} + +public struct CodexDeprecationNotice: Equatable, Sendable { + public var summary: String { get } + public var details: String? { get } +} + +public struct CodexConnectionEvents: AsyncSequence, Sendable { + public typealias Element = CodexConnectionEvent + public func makeAsyncIterator() -> Iterator + public func cancel() async + + public struct Iterator: AsyncIteratorProtocol { + public mutating func next() async -> CodexConnectionEvent? + } +} + +public struct CodexAccountEvents: AsyncSequence, Sendable { + public typealias Element = CodexAccountEvent + public func makeAsyncIterator() -> Iterator + public func cancel() async + + public struct Iterator: AsyncIteratorProtocol { + public mutating func next() async throws -> CodexAccountEvent? + } +} + +public enum CodexAccountEvent: Equatable, Sendable { + case accountChanged + case rateLimitsUpdated(CodexRateLimits) + case malformed(method: String, message: String) + case unknown(CodexRawNotification) +} + +public actor CodexAppServer { + public func connectionEvents() -> CodexConnectionEvents + public func accountEvents() -> CodexAccountEvents + public func close() async +} + +public struct CodexThread { + public func closeConnection() async +} + +package struct CodexTurn { + package func closeConnection() async +} + +package struct CodexResponseStream { + package func closeConnection() async +} + +public struct CodexReviewSession { + public func closeConnection() async +} +``` + +- `CodexDeprecationNotice` は pinned `DeprecationNoticeNotification` の `summary` / `details` をlosslessに保持する。`details` はmigration stepsまたはrationaleを含み得るため、`feature` / `replacement` を推測で合成しない。 +- connection/account sequencesはroot-bound non-retaining subscriptionで、connection leaseを持たずapp-server processを延命しない。sequence/iteratorのtask cancellation、`cancel()`、最後のcopy releaseはいずれもそのsubscriberだけをunsubscribeする。root `CodexAppServer`の明示closeはconnection sequenceへ`.terminated(.closedByCaller)`を必達させて両sequenceをfinishし、transport/process failureはconnection sequenceへtyped terminalをyieldしたうえでaccount iteratorを`CodexAppServerError.connectionTerminated`で終了する。 +- `ConnectionTerminationArbiter`は`ConnectionSupervisor` isolation内のvalueであり、supervisor methodが最初に受理したterminal causeを別actorへのawait前に固定する。explicit close requestがEOF/process exit signalより先なら`.closedByCaller`、transport failureが先なら`.transportFailure`、process waiter exitが先なら`.processExited`である。malformed notificationやreplay contract violationは具体的なcauseをdiagnosticへ残し、arbiterへ対応する`.transportFailure(.protocolViolation/.contractViolation)`をclaimする。全generation/account sequenceは同じwinnerから`CodexAppServerError.connectionTerminated`で終了し、別の`firstDomainError`をclosure payloadとして保持しない。後着causeはdiagnosticへ残すだけでpublic terminalを上書きしない。hubはwinnerから派生したbounded diagnosticsとcompact terminalだけを保持し、late connection subscriberへterminal 1件をreplayしてfinishする。 +- `close()` は `ConnectionSupervisor` のshared close taskへ収束し、§5.3の固定順序を最後まで完了する。 +- rootと全live-capable child handleはconnectionごとに1つの同じ `AppServerConnectionLease` instanceをstrong retainし、leaseはsupervisorをstrong参照する。supervisorはlease objectをretainしないため、handle→single lease→supervisor→connectionの一方向である。public `CodexThread` / `CodexReviewSession`、package `CodexResponseStream` / `CodexTurn` の `closeConnection()` とroot `close()` は同じsupervisor completionへ到達し、root valueのARC解放だけで有効なchild handleを壊さない。 +- router / `AppServerConnection` は Task handleを保持しない。`ConnectionSupervisor` がrouter/control run Taskを生成・保持し、closeでcancel + awaitする。 +- response/review/per-turn handleの全value copyは同じpackage actor `TurnGenerationHandleState`をstrong retainする。stateは`.live(AppServerConnectionLease)`から`.terminal(CompactTurnSnapshot)`または`.terminated(CodexConnectionTermination)`へ一度だけ遷移し、terminal transitionと同じactor transactionでstrong connection leaseをreleaseする。`TurnReplayStore`はstateをweak registrationだけで参照し、terminal snapshot valueを渡してstate transitionをawaitした後にraw generationを削除するためcycleを作らない。strong graphはlive時`handle → generation state → lease → supervisor → connection → replay store ⇢ weak state`、terminal時`handle → generation state → compact snapshot`である。 +- TTL/LRU は採用しない。consumer がterminal handleを保持する限り repeated late `collect()` / `result()` を保証し、最後の handle/state解放でsnapshotも解放する。reusable `CodexThread` はID + connection leaseだけを保持しgeneration stateをretainしない。requestのstructured local scopeから返却されたresponse/review handleへgeneration stateを移譲し、`TurnReplayStore`はraw routing中もweak registrationだけを持つため、connection→state→lease cycleと過去snapshot蓄積を作らない。 +- terminalへ切り替わったper-turn handleの `closeConnection()` はidempotent no-opである。再利用可能なthread/rootは引き続きshared authorityを閉じられる。 +- package review-event/response/progress iteratorはsubscriberごとにcapacity 256のbounded relayを `TurnReplayStore` へ登録する。incremental event overflow時はそのsubscriberのpending incremental eventsをcurrent accumulated `CodexTurnSnapshot` 1件へatomic compactし、snapshot以後のeventsだけを後置する。terminalはreserved non-droppable control slotを使い、必要ならfinal snapshot→terminalの順でdeliveryしてfinishする。slow subscriberはrouterや別subscriberをblockせず、overflowはdiagnosticへ記録する。public `CodexReviewSession`はincremental sequenceを公開せず、cached `collect/cancel/closeConnection`と、wire request・waiter・別cacheを作らず同じgeneration stateを読む`terminalOutcomeIfKnown()`だけを提供する。 +- late review/response iteratorはraw historyをreplayせず、compact terminal snapshot→terminal eventの最大2件をyieldしてfinishする。package progress projectionはcumulative snapshotなのでbuffer newest 1 + reserved terminalとし、incremental item/delta projectionへ使わない。 +- reusable threadのpackage event sequenceは `ThreadEventHub` が所有する。`withThreadEventGeneration` はrequest送信前にopaque `ThreadEventGenerationCheckpoint`を登録し、request中のearly eventをそのcheckpointのbounded compact stateへroutingする。response successはcheckpointをcurrent generationへatomic commitし、failure/cancellationはexplicit discardする。`Int` history cursor、post-response global-history slicing、detached bridge Taskは残さない。detached reviewだけはresponseで確定したturn IDを `beginGeneration(including:)` へ渡し、hub内の同turn compact stateを採用する。 +- `ThreadEventHub` はthreadごとにcurrent generationを最大1件だけ保持し、subscriberごとにcapacity 256のbounded channelを持つ。257件目ではknown incremental eventsをcurrent `CodexTurnSnapshot` + newest token usage/statusへatomic compactし、unknown diagnosticsはbounded newest slotsだけを残す。terminal/closedは**current generation内**のnon-droppable control eventで、`final snapshot → terminal → post-terminal status/unknown → closed`の因果順を保つ。同一turnの同値terminalはexactly-once、異なるterminalはcontract violationである。新generationのcommit/resetは、bounded/nonblockingを維持するため未消費controlを含む旧generation queue全体をnew compact generationでatomic supersedeする。generation resetまたはconnection closeで旧stateを解放する。 +- thread event sequence/iteratorのtask cancellation、explicit cancel、最後のcopy releaseはsynchronous cancellation endpointでそのsubscriberだけをremoveし、待機continuationをresumeする。hub、sequence、routerはTaskやconnection leaseを保持しない。connection failureはpending raw eventsを破棄して全subscriberとlate subscriberを同じ `CodexAppServerError.connectionTerminated` で終了し、thread/closedはcompact stateをdelivery後にnormal finishする。 +- connection subscriberはwarning/retry/deprecation/unknownのnewest 32件 + reserved terminalだけを持つ。terminalは過去diagnosticをsupersedeして次deliveryとなり、late subscriberへ1件replayしてfinishする。 +- `AccountEventHub` はsparse rate-limit updateをfull `CodexRateLimits`へmergeしてfan-outし、payloadがfull accountでない`account/updated`はcoalescible `.accountChanged` invalidationとしてnewest 1件を保持する。full accountが必要なconsumerは`account()`をexplicit refetchする。subscriber bufferはnewest account invalidation 1 + newest rate-limit 1 + newest diagnostic 16とする。login terminalはbroad account streamへ入れずID-correlated `CodexLoginHandle.result()`だけが所有する。connection terminalをdropせず、raw sparse updateやunbounded historyをsubscriberへ移さない。 +- 再利用可能な `CodexThread` はconnection leaseだけを保持し、active/terminal generationを保持しない。各 response/review handleが自分のgeneration stateとterminal snapshotを所有するため、threadで次のturnを開始しても既存handleのlate resultを壊さない。 +- start/resume/review operation は request送信前に per-turn generation leaseとthread generation checkpointを登録し、request中に届くearly eventを同じownerへroutingする。routerのglobal append-only historyから後で拾う経路は残さない。 +- 全logical handle/value copyはconnection-wide single leaseを共有する。connection closeは残る全active generationをtyped terminationにする。 +- explicit `closeConnection()` completionが唯一のgraceful-close contractである。single leaseの最後のcopyが明示closeなしにdropされた場合、deinitはactor hopやownerless Taskを作らず`ProcessTerminationToken.terminateOnce()`だけを同期実行してchild-process leakを防ぐ。domain stream finish/task join/reap completionは保証しないため、tests/production compositionは必ず明示closeをawaitする。 + +### 5.5 Typed server requests and stock login + +```swift +package enum CodexAppServerRequest: Sendable { + case commandExecutionApproval(CodexCommandExecutionApprovalRequest) + case fileChangeApproval(CodexFileChangeApprovalRequest) + case userInput(CodexUserInputRequest) + case mcpElicitation(CodexMCPElicitationRequest) + case permissions(CodexPermissionsRequest) + case dynamicToolCall(CodexDynamicToolCallRequest) + case chatGPTAuthTokensRefresh(CodexChatGPTAuthTokensRefreshRequest) + case attestationGenerate(CodexAttestationGenerateRequest) + case currentTimeRead(CodexCurrentTimeReadRequest) + case unknown(CodexRawServerRequest) +} + +package enum CodexAppServerRequestResolution: Sendable { + case approval(CodexApprovalDecision) + case userInput(CodexUserInputResponse) + case permissions(CodexPermissionsResponse) + case dynamicToolCall(CodexDynamicToolCallResponse) + case mcpElicitation(CodexMCPElicitationResponse) + case chatGPTAuthTokensRefresh(CodexChatGPTAuthTokensRefreshResponse) + case attestationGenerate(CodexAttestationGenerateResponse) + case currentTimeRead(CodexCurrentTimeReadResponse) + case rejectUnknown(code: Int, message: String) +} + +package typealias CodexAppServerRequestHandler = + @Sendable (CodexAppServerRequest) async throws -> CodexAppServerRequestResolution + +public enum CodexLoginOutcome: Equatable, Sendable { + case succeeded + case authenticationCommittedNeedsConnectionReconciliation( + CodexLoginReconciliationReason + ) + case failed(message: String?) + case cancelled +} + +public enum CodexLoginReconciliationReason: Equatable, Sendable { + case connectionTerminated(CodexConnectionTermination) + case accountReadinessDeadlineExceeded(Duration) + case chatGPTAccountUnavailableAfterSuccess + case malformedAccountUpdateAfterSuccess(CodexMalformedNotification) + case cancelOutcomeUnknown(CodexRequestFailure?) +} + +public struct CodexLoginHandle: Identifiable, Equatable, Sendable { + public struct ID: + RawRepresentable, + Hashable, + Codable, + Sendable, + ExpressibleByStringLiteral + { + public var rawValue: String + public init(rawValue: String) + public init(stringLiteral value: String) + } + + public var id: ID { get } + public var authenticationURL: URL { get } + + public func result() async throws -> CodexLoginOutcome + @discardableResult + public func cancel( + acknowledgementTimeout: Duration? = nil + ) async throws -> CodexLoginOutcome + public func closeConnection() async +} + +public actor CodexAppServer { + public func loginChatGPT( + accountReadinessTimeout: Duration? = nil + ) async throws -> CodexLoginHandle +} +``` + +- registry は request case と resolution case の一致を検証する。不一致、handler throw、response encode failureはrequest元へJSON-RPC internal errorを1回返し、typed connection diagnosticもemitする。`serverRequest/resolved` が先に届いたrequestはhandlerをcancel + awaitしてresponseを送らない。connection closeも全handlerをcancel + awaitする。 +- repo内のproduction consumerはcustom inbound request policyを設定していないため、handler/request/resolution familyとConfiguration handler injectionはpackageにする。public hostは下表のbuilt-in current-v2 policyだけを使う。external fixtureはpublic needの根拠にせず、将来interactive hostの実在consumerが現れた時だけAPI-first gateを経てmethod-specific familyをpublicへ昇格する。 +- unsupported provider/unknown methodはJSON-RPC `-32601`、handler throw/case mismatch/response encode failureは `-32603` を使う。どのpathも同じrequest IDへresult/errorを高々1回だけwriteする。 +- configurationのhandlerがnilなら表のbuilt-in policyを使い、非nilなら全typed casesのpolicy ownerはそのhandlerへ移る。built-in `currentTimeRead` だけはconfigurationの `CodexAppServerClock` を使う。 +- pinned current-v2 server-request inventoryとdefault policyは次で固定する。client capability/providerを注入した場合だけunsupported defaultを置換できる。 + +| Wire method | Typed case | Default policy | +|---|---|---| +| `item/commandExecution/requestApproval` | `commandExecutionApproval` | method-specific decline | +| `item/fileChange/requestApproval` | `fileChangeApproval` | method-specific decline | +| `item/tool/requestUserInput` | `userInput` | `{ answers: [:] }` | +| `mcpServer/elicitation/request` | `mcpElicitation` | MCP cancel response | +| `item/permissions/requestApproval` | `permissions` | empty permission profile、turn scope、strict auto-review false | +| `item/tool/call` | `dynamicToolCall` | `success:false` + one typed text failure item | +| `account/chatgptAuthTokens/refresh` | `chatGPTAuthTokensRefresh` | provider未設定のexplicit unsupported JSON-RPC error | +| `attestation/generate` | `attestationGenerate` | provider未設定のexplicit unsupported JSON-RPC error | +| `currentTime/read` | `currentTimeRead` | injected clockのUnix seconds | +| unknown current/future method | `unknown` | method-not-found JSON-RPC error | + +legacy `applyPatchApproval` / `execCommandApproval` requestはcurrent-v2 inventoryから除外する。§1のMCP non-goalはclient-initiated `mcpServer/*` APIの追加を指し、serverから受ける `mcpServer/elicitation/request` はこのinbound lifecycleのscopeである。 + +- stock login は `account/login/start`、`account/login/cancel`、`account/login/completed` notificationだけを使う。SDKの `CodexNativeWebAuthentication`、native callback metadata、`account/login/complete`、`completeLogin` を削除する。Hostはchallenge URLを一度だけinjected URL openerへ渡し、callback interceptionやweb-session tokenを持たない。 +- `loginChatGPT(accountReadinessTimeout:)` はrequest前に`LoginRegistry`へpending slotを確保し、pending/boundを問わずliveな既存stateがあればwire requestを送らずID-less `.loginAlreadyInProgress`をthrowする。start response前はIDがまだないため、conflict errorへIDをfabricateしない。start failureまたはpre-write caller cancellationはpending slotとleaseを同じcleanupでreleaseする。post-write caller cancellationはslot/stateを維持し、response ID/URLをbind→URLを開かずcancel winnerをawait→lease/slot release後に`CancellationError`を返す。registryはpending tokenまたはactive ID + weak stateだけを持ち、nilなら次start時にslotをclearする。structured start request scopeから返却`CodexLoginHandle` / Host `LoginSession`へstateのstrong ownershipを移し、strong graphを`handle/session → login state → lease → supervisor → connection → registry ⇢ weak state`に固定する。readiness timeoutはwaiter propertyではなくこのshared login operationのimmutable configurationで、複数`result()` waiterがdeadlineを競合・短縮しない。 +- start responseのID/URLを同じstateへbindし、routerは`account/login/completed`をID一致するweak stateから一時strong化してexactly once applyする。wire failureは直ちに`.failed(message:)` terminal、successは**winnerをsuccessにclaimするがpublic resultをまだresumeせず**`.successAwaitingAccountUpdate`へ進む。pinned upstream `account_processor.rs` はsuccess notification送信後に`AuthManager.reload()`し、そのcacheから`account/updated { authMode, planType }`を送るため、same causal inbound laneで次にdecodeしたpost-success updateをactive success stateへ先に渡す。stock browser loginに一致する`authMode == .chatGPT`だけがreadiness acknowledgementで、そこから`AccountEventHub`へfan-outする同transactionで初めて`.succeeded`をresolveする。これによりhandle success直後の`account()`がno-auth→newでnil、A→Bで旧Aまたは別auth providerを返すraceをAPI boundaryで閉じる。 +- matching ID payload malformed、sole active browser login中のmissing IDはそのhandleをtyped malformed/connection failureへresolveし、successをfabricateしない。mismatched/unknown IDはcancel→new-login raceの旧notificationかもしれないためdiagnostic + dropに留め、新active stateを失敗させない。success claim後のaccount update payload malformedは`.authenticationCommittedNeedsConnectionReconciliation(.malformedAccountUpdateAfterSuccess(...))`、well-formedでも`authMode == nil`または`.chatGPT`以外なら`.authenticationCommittedNeedsConnectionReconciliation(.chatGPTAccountUnavailableAfterSuccess)`へresolveしてactual modeをbounded diagnosticへ残す。success notificationがauth disk mutationを既に報告しているため、どちらもordinary `.failed`や`.cancelled`へ戻さない。global/API-key completionや既存broad account snapshotへfallbackしない。success readinessをackできるのはsuccess notificationより**後ろ**かつ`.chatGPT`のaccount updateだけで、先行/stale updateは無視する。 +- `result()`はbrowser/user login pending全体にはdeadlineを掛けず、explicit cancel/Host stop/connection terminalまで待てる。login start時のoptional readiness timerはsuccess notificationがwinnerをclaimして`.successAwaitingAccountUpdate`へ入った瞬間にだけ開始する。late/repeated awaitでもsame compact outcomeを返し、success claim前のconnection failureはtyped terminationをthrowするが、success pending中のconnection terminal/deadline/nil-or-malformed account acknowledgementはauth disk commitを巻き戻せないため`.authenticationCommittedNeedsConnectionReconciliation`へresolveする。Hostはprimary sign-inならruntime full restart後にactual accountを読みforward reconciliation、isolated add-accountならregistry commitせずstaging cleanupする。 +- result caller Taskのcancellationはそのwaiter tokenだけを同期remove + `CancellationError` resumeし、shared loginや別waiterをcancelしない。server/explicit lifecycle cancellationは`CancellationError`を偽装せず`.cancelled` valueである。`cancel(acknowledgementTimeout:)`だけがcancel request/ackへfinite deadlineを掛ける。最初にactor上でcancelをclaimしたcallerのtimeoutをshared cancel operationがimmutableに所有し、後続cancel callerは引数でdeadlineを変更せずsame completionへjoinする。response/write outcomeが不明なら`.cancelOutcomeUnknown`としてprimary runtime stop/restart + account reconciliationへ進む。success claim前のdefinite canceled ackだけを`.cancelled`とする。 +- `cancel()` は同handle IDの`account/login/cancel` responseをdecodeする。`.canceled` ackはpending stateを`.cancelled`へresolveする。`.notFound`もmatching active loginがserverに存在しないauthoritative ackなので、success winner claim前なら`.cancelled`、既にfailure/success winnerがあればそのwinnerを維持する。特に`.successAwaitingAccountUpdate`ではcancel responseが後着してもsuccess winnerを変えずreadinessを待つ。matching completionとcancel responseはfirst-terminal-winsで、後着側はdiagnostic correlationだけを残す。cancelはwinnerのready outcomeを返し、terminal/closeでstateはlease/registry slotをreleaseする。 + +### 5.6 DataKit query/load/observation + +#### Apple API evidence and adaptation rule + +Xcode 26.6 DocumentationSearch、macOS 26.5 SDK interface、Swift 6 strict-concurrency probeを照合した。`ResultsObserver` / `HistoryObserver`を含むApple APIは、CodexDataKitで同名・同形の型を実装するためではなく、owner、identity、isolation、更新契約を評価するための設計資料として扱う。DocumentationSearchに存在してinstalled SDKに未収録のAPIも設計資料としては参照する一方、実装availabilityの根拠にはしない。 + +- `ModelContext: Equatable, SendableMetatype`とunavailable `Sendable`から、context instanceは1 isolation domainに閉じ、metatypeだけを境界越しに使える契約を採る。`CodexModelContext`も同じconformanceとidentity equalityを持ち、model instanceをactor間へ渡さずtyped ID / immutable snapshotを渡す。 +- `ModelContainer: Equatable, @unchecked Sendable`の表面形はコピーしない。Codex containerはcompilerがchecked `Sendable`を証明できるため、`CodexModelContainer: Equatable, Sendable`を維持する。`Sendable`は`SendableMetatype`を含むので重複conformanceを足さない。 +- Core Dataの`NSManagedObjectContext: Sendable`は`perform`へ仕事を投入するqueue-scheduling handleの契約であり、同期的にcontext-local graphを操作するCodex contextへ転用しない。Core Data/SwiftDataのsave、rollback、fault、persistent-history tokenも、remote app-server authorityに対応する実契約がないため模倣しない。 +- `ResultsObserver`から採るのは、query criteria・context・current results・更新通知を1 ownerへ閉じること、差分計算へstable identity orderingを要求すること、typed section identityを使うこと、caller isolationを明示することだけである。型名、section-key primary sort、cross-process automatic history ingestionは採らない。 +- Codexのsection contractは、global query order/offset/limitを先に確定し、そのwindowをsectionへgroupするprojectionである。section順は最初のmemberの出現順、section内はglobal relative orderを維持する。workspace section keyをprimary sortへ暗黙追加してglobal recency semanticsを変えない。 +- app-serverにdurable history tokenがないため、同一contextのmutationだけをlive反映し、別process/clientの変更はexplicit `refresh()`で取得する。network streamのgeneration/sequence、snapshot barrier、bounded delta、明示closeは`CodexChatObservation`固有の契約として維持する。 + +```swift +public enum CodexFetchValidationError: Error, Hashable, LocalizedError, Sendable { + case unsupportedModel(String) + case unsupportedPredicate(String) + case unsupportedSort(String) + case unsupportedSection(String) + case invalidArchiveScope(String) + case negativeFetchLimit(Int) + case negativeFetchOffset(Int) +} + +public enum CodexFetchFailure: Error, Equatable, LocalizedError, Sendable { + case validation(CodexFetchValidationError) + case appServer(CodexAppServerError) +} + +public enum CodexFetchPhase: Equatable, Sendable { + case idle + case loading + case loaded + case failed(CodexFetchFailure) +} + +public enum CodexModelContextError: Error, Equatable, Sendable { + case unsupportedModelType(String) + case modelIsDetached +} + +public struct CodexReviewInput: Sendable { + public var target: CodexReviewTarget + public var instructions: CodexInstructions? + public var options: CodexThread.Options + public var delivery: CodexReviewDelivery + + public init( + target: CodexReviewTarget, + instructions: CodexInstructions? = nil, + options: CodexThread.Options = .init(), + delivery: CodexReviewDelivery = .inline + ) +} + +public enum CodexTurnTerminalDisposition: Equatable, Sendable { + case completed + case interrupted + case failed + case invalid(rawStatus: String) +} + +public enum CodexChatPhase: Equatable, Sendable { + case idle + case loading + case running(turnID: CodexTurnID) + case terminal( + turnID: CodexTurnID, + disposition: CodexTurnTerminalDisposition + ) + case failed(CodexFetchFailure) + + public var turnID: CodexTurnID? { get } +} + +public final class CodexModelContainer: Equatable, Sendable { + public let appServer: CodexAppServer + @MainActor public let mainContext: CodexModelContext + + @MainActor public init(appServer: CodexAppServer) +} + +public protocol CodexModelActor: Actor { + nonisolated var modelContainer: CodexModelContainer { get } + nonisolated var modelExecutor: CodexDefaultSerialModelExecutor { get } +} + +public final class CodexDefaultSerialModelExecutor: + @unchecked Sendable, + SerialExecutor +{ + public init(modelContainer: CodexModelContainer) + public func enqueue(_ job: consuming ExecutorJob) + public func asUnownedSerialExecutor() -> UnownedSerialExecutor +} + +public extension CodexModelActor { + nonisolated var unownedExecutor: UnownedSerialExecutor { get } + var modelContext: CodexModelContext { get } +} + +public final class CodexModelContext: Equatable, SendableMetatype { + public init(_ container: CodexModelContainer) + + public nonisolated(nonsending) func fetch( + _ descriptor: CodexFetchDescriptor + ) async throws -> [Model] + public func fetchedResults( + for descriptor: CodexFetchDescriptor, + sectionedBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResults + + public func model(for id: CodexThreadID) -> CodexChat + public func registeredModel(for id: CodexThreadID) -> CodexChat? + public func model(for id: CodexWorkspaceID) -> CodexWorkspace? + public func registeredModel(for id: CodexWorkspaceID) -> CodexWorkspace? + public func model(for id: CodexWorkspaceGroupID) -> CodexWorkspaceGroup? + public func registeredModel(for id: CodexWorkspaceGroupID) -> CodexWorkspaceGroup? + + public nonisolated(nonsending) func refresh( + _ group: CodexWorkspaceGroup + ) async throws + public nonisolated(nonsending) func refresh( + _ workspace: CodexWorkspace + ) async throws + public nonisolated(nonsending) func refresh( + _ chat: CodexChat, + includeTurns: Bool = true + ) async throws + public nonisolated(nonsending) func observe( + _ chat: CodexChat, + includeTurns: Bool = true + ) async throws -> CodexChatObservation + + @discardableResult + public nonisolated(nonsending) func startChat( + in workspace: CodexWorkspace, + input: CodexChatInput = .init() + ) async throws -> CodexChat + @discardableResult + public nonisolated(nonsending) func startReview( + in workspace: URL, + input: CodexReviewInput + ) async throws -> CodexStartedReview + @discardableResult + public nonisolated(nonsending) func startReview( + in workspace: CodexWorkspace, + input: CodexReviewInput + ) async throws -> CodexStartedReview + @discardableResult + public nonisolated(nonsending) func send( + _ input: CodexChatMessageInput, + in chat: CodexChat + ) async throws -> CodexTurnOutcome + public nonisolated(nonsending) func cancelActiveTurn( + in chat: CodexChat + ) async throws + public nonisolated(nonsending) func archive(_ chat: CodexChat) async throws + public nonisolated(nonsending) func unarchive(_ chat: CodexChat) async throws + public nonisolated(nonsending) func delete(_ chat: CodexChat) async throws +} + +@available(*, unavailable, message: "CodexModelContext instances cannot be shared across concurrency domains. Use CodexModelActor or typed model IDs.") +extension CodexModelContext: Sendable {} + +// CodexDataKit model (not the package-only live AppServerKit handle) +public final class CodexTurn: CodexPersistentModel { + public private(set) var error: CodexTurnError? +} + +public final class CodexItem: CodexPersistentModel { + // All baseline identity/content/owner accessors remain public. + public private(set) var origin: CodexThreadItem.Origin + public private(set) var semanticRelation: CodexThreadItem.SemanticRelation? +} + +public final class CodexChat: CodexPersistentModel { + // All baseline identity/metadata/relationship/query accessors remain public. + public private(set) var phase: CodexChatPhase + public func transcript(in turnID: CodexTurnID) -> CodexTranscript + // lastErrorDescription is deleted; phase is the only failure owner. +} + +public struct CodexSortDescriptor: Hashable, Sendable { + public init( + _ keyPath: any KeyPath & Sendable, + order: SortOrder = .forward + ) + + public init( + _ keyPath: any KeyPath & Sendable, + order: SortOrder = .forward + ) + + public init( + _ keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward + ) + + public init( + _ keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward + ) +} + +public struct CodexSectionDescriptor: Hashable, Sendable { + public init( + _ keyPath: any KeyPath & Sendable + ) + public init( + _ keyPath: any KeyPath & Sendable + ) +} + +public extension CodexSectionDescriptor where Model == CodexWorkspace { + static var workspaceGroup: Self { get } +} + +public extension CodexSectionDescriptor where Model == CodexChat { + static var workspaceGroup: Self { get } + static var workspace: Self { get } +} + +public struct CodexFetchDescriptor: Sendable { + public var predicate: Predicate? + public var sortBy: [CodexSortDescriptor] + public var fetchLimit: Int? + public var fetchOffset: Int? + public var includeContextChanges: Bool + + public init( + predicate: Predicate? = nil, + sortBy: [CodexSortDescriptor] = [], + fetchLimit: Int? = nil, + fetchOffset: Int? = nil, + includeContextChanges: Bool = true + ) +} + +package enum CodexFetchPlanResult { + package struct Signature: Hashable, Sendable { + // canonical archive/predicate/field/order/section/limit/offset facts + } + + case valid(signature: Signature, plan: CodexThreadQueryPlan) + case invalid(signature: Signature, failure: CodexFetchValidationError) + + package var signature: Signature { get } +} + +public struct CodexQueryResults: RandomAccessCollection { + public typealias Index = Array.Index + public typealias Element = Model + public var items: [Model] + public var sections: [CodexFetchSection] + public var phase: CodexFetchPhase + + public init( + items: [Model] = [], + sections: [CodexFetchSection] = [], + phase: CodexFetchPhase = .idle + ) + public var startIndex: Index { get } + public var endIndex: Index { get } + public subscript(position: Index) -> Model { get } +} + +@MainActor +@propertyWrapper +public struct CodexQuery: DynamicProperty { + public var wrappedValue: CodexQueryResults { get } + + public init( + _ descriptor: CodexFetchDescriptor = .init(), + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort: [CodexSortDescriptor] = [], + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + sectionBy: CodexSectionDescriptor? = nil + ) + public mutating func update() +} + +public extension EnvironmentValues { + var codexModelContext: CodexModelContext? { get set } +} + +public extension View { + func codexModelContainer(_ container: CodexModelContainer) -> some View + func codexModelContext(_ context: CodexModelContext) -> some View +} + +public struct CodexFetchedResultsIndexPath: Sendable, Hashable { + public var section: Int + public var item: Int + public init(section: Int, item: Int) +} + +public struct CodexFetchedResultsSnapshot: + Sendable, + Hashable +{ + public struct Section: Identifiable, Sendable, Hashable { + public var id: CodexFetchSectionID + public var title: String? + public var itemIDs: [ItemID] + public init(id: CodexFetchSectionID, title: String?, itemIDs: [ItemID]) + } + + public var sections: [Section] + public init(sections: [Section] = []) + public var sectionIDs: [CodexFetchSectionID] { get } + public var itemIDs: [ItemID] { get } + public func itemIDs(in sectionID: CodexFetchSectionID) -> [ItemID]? +} + +public enum CodexFetchedResultsSectionChange: Sendable, Hashable { + case insert(sectionID: CodexFetchSectionID, index: Int) + case delete(sectionID: CodexFetchSectionID, index: Int) + case move(sectionID: CodexFetchSectionID, from: Int, to: Int) + case update(sectionID: CodexFetchSectionID, index: Int) +} + +public enum CodexFetchedResultsItemChange: + Sendable, + Hashable +{ + case insert(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) + case delete(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) + case move( + itemID: ItemID, + from: CodexFetchedResultsIndexPath, + to: CodexFetchedResultsIndexPath + ) + case update(itemID: ItemID, indexPath: CodexFetchedResultsIndexPath) +} + +public enum CodexFetchedResultsTransactionReason: Sendable, Hashable { + case initialFetch + case refresh + case pageAppend + case insert + case archive + case remove + case revalidate +} + +public struct CodexFetchedResultsTransaction: + Sendable, + Hashable +{ + public typealias ItemID = Model.ID + public var reason: CodexFetchedResultsTransactionReason + public var oldSnapshot: CodexFetchedResultsSnapshot + public var newSnapshot: CodexFetchedResultsSnapshot + public var sectionChanges: [CodexFetchedResultsSectionChange] + public var itemChanges: [CodexFetchedResultsItemChange] + public var isInitialFetch: Bool { get } + public var hasChanges: Bool { get } + + public init( + reason: CodexFetchedResultsTransactionReason, + oldSnapshot: CodexFetchedResultsSnapshot, + newSnapshot: CodexFetchedResultsSnapshot, + sectionChanges: [CodexFetchedResultsSectionChange], + itemChanges: [CodexFetchedResultsItemChange] + ) +} + +@Observable +public final class CodexFetchedResults { + public let modelContext: CodexModelContext + public private(set) var fetchDescriptor: CodexFetchDescriptor + public private(set) var sectionBy: CodexSectionDescriptor? + public private(set) var items: [Model] + public private(set) var sections: [CodexFetchSection] + public private(set) var nextCursor: String? + public private(set) var backwardsCursor: String? + public private(set) var phase: CodexFetchPhase + public var snapshot: CodexFetchedResultsSnapshot { get } + public var transactions: AsyncStream> { get } + + public nonisolated(nonsending) func performFetch() async throws + public nonisolated(nonsending) func refresh() async throws + public nonisolated(nonsending) func loadNextPage() async throws +} + +public struct CodexChatObservationSnapshot: Equatable, Sendable { + public var thread: CodexThreadSnapshot + public var phase: CodexChatPhase +} + +public struct CodexChatObservationEvent: Equatable, Sendable { + public enum Payload: Equatable, Sendable { + case snapshot( + CodexChatObservationSnapshot, + reason: CodexChatSnapshotReason + ) + case update(CodexChatUpdate) + } + + public var generation: UInt64 { get } + public var sequence: UInt64 { get } + public var payload: Payload { get } +} + +public struct CodexChatUpdates: AsyncSequence, Sendable { + public typealias Element = CodexChatObservationEvent + public struct AsyncIterator: AsyncIteratorProtocol { + public mutating func next() async -> Element? + } + + public func makeAsyncIterator() -> AsyncIterator +} + +public final class CodexChatObservation { + public let chat: CodexChat + public let updates: CodexChatUpdates + public nonisolated(nonsending) func close() async +} + +public extension CodexChat { + nonisolated(nonsending) func observe( + includeTurns: Bool = true + ) async throws -> CodexChatObservation +} + +public enum CodexChatSnapshotReason: Equatable, Sendable { + case initial + case refresh + case includeTurnsUpgrade + case generationRestart + case bufferOverflow + case upstreamFailure +} + +public enum CodexChatUpdate: Equatable, Sendable { + case turnInserted(CodexTurnSnapshot, index: Int) + case turnUpdated(CodexTurnSnapshot, index: Int) + case turnRemoved(id: CodexTurnID) + case itemInserted(item: CodexThreadItem, turnID: CodexTurnID, index: Int) + case itemUpdated(item: CodexThreadItem, turnID: CodexTurnID, index: Int) + case itemRemoved(CodexChatItemLocator) + case itemTextAppended(CodexChatItemLocator, delta: String) + case statusChanged(CodexThreadStatus?) + case phaseChanged(CodexChatPhase) +} + +public struct CodexChatItemLocator: Equatable, Hashable, Sendable { + public let id: String + public let kind: CodexThreadItem.Kind + public let turnID: CodexTurnID +} +``` + +- Foundation `Predicate` surfaceは維持する。Foundation `SortDescriptor` のprivate layoutを `Mirror` で読む実装は削除し、CodexDataKitが意味論を所有する `CodexSortDescriptor` へsource-breaking移行する。Xcode Documentationの `SortDescriptor.keyPath` は `PartialKeyPath?` で、`Compared` がNSObjectでない場合はnilと明記されるため、pure Swift Codex modelのstable lowering keyにはできない。 +- `CodexModelContainer` はMainActor compositionでmain contextをeager生成し、baselineのlazy getter / pending transaction buffer / fire-and-forget delivery Taskを削除する。HostとfixtureはMainActorでcontainerを作り、`AppServerCodexReviewBackend`はcontainerとconcrete executorをrequired注入してactor内部default生成をしない。containerはborrowed `CodexAppServer`だけを受け取り、serverを内部生成するconvenience initializerは削除する。process/runtimeのclose authorityはserver ownerに残し、containerから`appServer.close()`を要求する二重owner APIを作らない。 +- container facadeとcontext-family coordinationを同一lifetimeにしない。package `CodexModelContextCoordinator`がapp server associationとweak main-context registrationを持ち、各contextはcoordinatorをstrong保持する。これによりcontainer→mainContext→container cycleを作らず、callerがmain contextだけを保持してもmulticast contractをsilentに失わない。public `context.container`は、facade lifetimeを意味論へ誤って持ち込むため公開しない。`CodexModelContext: Equatable, SendableMetatype`、identity equality、unavailable `Sendable`を公開契約とし、`CodexModelContainer: Equatable, Sendable`はchecked conformanceを維持する。 +- generic public `CodexModelExecutor` / `CodexSerialModelExecutor` protocolsとそれら旧protocolのpublic context getterは削除する。実consumerが使う`CodexModelActor` requirementをconcrete `CodexDefaultSerialModelExecutor`へ縮め、executorのprivate contextはserial queue上のjobからだけaccessする。actor-isolated `CodexModelActor.modelContext` computed propertyはactor body内でだけ使用でき、non-Sendable contextをcross-actorへ返すcallはSwift 6 compile failureにする。`@unchecked Sendable` invariantはprivate context、private queue、`enqueue` entryだけで閉じ、negative compile fixtureでescape不能を検証する。 +- `CodexFetchDescriptor`を唯一のcanonical query valueとする。Core Dataのentity/result/fault/batch/prefetch/copy semanticsを持たず、descriptorと同じ5 propertyを複製するだけのmutable `CodexFetchRequest`は削除する。`includePendingChanges`も永続化unit-of-workを示す誤った語彙なので、server snapshotから欠落したactive/observed/loading modelをcontext graphからmergeする実意味に合わせて`includeContextChanges`へsource-breaking renameする。 +- `CodexQuery` のkey-path convenience initializer familyも`CodexSortDescriptor`と同じrequired/optional ComparableおよびString comparator + `SortOrder`へ置換する。`CodexQueryResults` / `CodexFetchedResults`の`lastErrorDescription`は削除し、read-only `CodexFetchPhase`だけをfailure ownerにする。environmentにcontextがない状態は空の`.idle`へ落とさずcomposition errorとしてfail fastする。transaction適用に使われていない`animation` initializer parameterは削除する。 +- ordered transactionのconsumer storyはAppKit sidebarに実在するが、別ownerは不要である。`CodexFetchedResults`がquery criteria、items/sections/phase、snapshot、`oldSnapshot/newSnapshot` transaction relayを一意に所有し、全property/methodを転送するだけの`CodexFetchedResultsController`は削除する。public streamは`bufferingNewest(1)`とし、consumerのcurrent snapshotがtransaction.oldと一致すればgranular changesを適用し、不一致ならnew snapshotへreplaceする。owner deinitでstreamをfinishし、unbounded relayを残さない。 +- 新descriptorはFoundation 26.5 `.swiftinterface` のconstructor setに合わせ、required/optional `Comparable` とrequired/optional `String` + `String.StandardComparator`(`.localizedStandard` / `.localized` / `.lexical`)と`SortOrder`を保持する。optional valueのnil orderingもFoundation parity(forwardはnil first、reverseはnil last)に固定する。`Hashable & Sendable` で、planはkey pathをsupported field IDへlowering/validationする。`CodexFetchDescriptor.sortBy`、`CodexQuery` initializers、README/全consumerを同じwaveで `[CodexSortDescriptor]` へ移す。Foundation APIの完全なparallel DSLは作らない。 +- `CodexSectionDescriptor` initializerはunresolved key pathだけを保持し、model-specific supportはload時にtyped validationする。initializerで `preconditionFailure` しない。 +- plan constructionはthrowing ownerを持つ一方、SwiftUI update比較には成功/失敗どちらにもstable hashable signatureを持つpackage `CodexFetchPlanResult` を返す。同じinvalid descriptorがbody更新ごとに新しいfailure/refreshを発火しない。 +- `CodexFetchPhase.failed(CodexFetchFailure)` がquery failureの唯一のsource of truthである。別の `failure` / `lastErrorDescription` property、generic `CodexDataPhase.failed(String)` は削除する。presentation textはphaseのtyped failureからderiveする。`CodexChat` は別の `CodexChatPhase` を使い、fetchとturn lifecycleを混ぜない。 +- `CodexChatPhase.terminal` はfull `CodexTurnOutcome/CodexResponse` を保持しない。classifierのdispositionとturn IDだけをcopyし、typed turn error/transcript/itemsは `CodexTurn` / `CodexItem` graphから読む。observation/load failureだけは `.failed(CodexFetchFailure)` がownerである。 +- DataKit `CodexTurn.errorDescription` は削除し、lossless `CodexTurnError?` に置換する。status `.failed` ではerrorがrequired、running/completed/interruptedではnilというinvariantをsnapshot/event reducerが守り、failed+missing errorはmodelへcommitする前にmalformed contract failureとする。 +- explicit fetch/loadはtyped errorをthrowし、SwiftUI `CodexQuery` は同じfailureを `.failed` phaseへ投影してrender-time crashをなくす。`CancellationError` はphaseへ保存せず、直前のstable phaseへ戻す。 +- nil predicate は active-only。明示 predicate が archive 条件を含まない場合は active+archived を literal に評価する。implicit `archived == false` 注入は行わない。 +- 全query orderingはquery-plan ownerがstable semantic ID tie-breakを末尾へ追加する。明示sortが空なら`createdAt` descending + thread ID descendingをeffective defaultとする。pinned upstreamのcreated/updated cursorはthread IDを含まず同timestamp page boundaryで欠落し得るため、その2 sortとlocal predicate/sort pathは`recencyAt` + thread ID cursorでserver endまでenumerateしてからrequested effective sortをlocal適用する。server-ordered pagingを使えるのはupstream cursorと同じ`recencyAt` + thread ID orderingだけである。workspace/group local sortも同値時はtyped IDで決着し、transaction diffの順序を入力配列の偶然へ依存させない。 +- `.both` archive scopeはserver cursorを共有できないmulti-scope queryとして扱う。`archived:false` と `archived:true` を別々にserver endまで全page取得し、eligible active/observed/loading context modelsをmerge(`includeContextChanges == false` なら省略)→ thread IDでdedupe → literal predicate → local effective sort → local offset → local limitの順に適用する。server-ordered single-page pathや一方のcursorをもう一方へ流用する経路へ入れない。`.both` resultは取得時点でserver endなので、そのsnapshotに対する `loadNextPage()` はno-opである。 +- sectioningはeffective global sort、offset、limitの後に行うprojectionである。section keyをprimary sortへ挿入しない。同じsection IDのitemsは1 sectionへ集約し、section順はfirst occurrence、member順はglobal orderを維持する。この差異をApple `ResultsObserver`のsection-contiguity contractとの意図的な相違としてREADMEへ記録する。 +- freshness は同一 context の mutation/observationだけ live。外部 processの list changeは `refresh()` が必要。 +- load intents はcontext-isolated coordinator内で直列化する。initial `performFetch()` はpage 1を取得する。2回目以降の `performFetch()` と `refresh()` / mutation refreshは実行開始時のloaded countをtarget windowとしてcaptureするが、targetが0でも必ずserver page 1を取得し、その後eligible merged resultがtarget以上またはserver endになるまで全pageをstagingする。これによりempty snapshot後の新規itemも発見する。items/cursors/phaseは成功時に1回だけatomic commitし、途中pageをobservable stateへ出さない。 +- queued intentのcaller cancellationはqueueから除去する。in-flight cancellationはstagingを破棄し、旧items/cursors/stable phaseを維持してfailureを保存しない。`loadNextPage()` は実行開始時のcurrent cursorを読み、nilならno-op、成功時だけappend + cursorをatomic commitする。coordinatorから別actorへnon-Sendable modelを送らない。 +- mutation strategy は query plan が `.applyLocally` / `.removeLocally` / `.refreshLoadedWindow` を決め、各 handlerは実行だけ行う。 +- 同じ chat の `ChatObservationOwner` はcontext isolation内で `starting / active(includeTurns:) / upgrading / closing` を遷移する。`observe(false)` のstart中に来た `observe(true)` は同じstart Taskへjoinし、その後true upgrade completionまでawaitしてからhandleを返す。upgrade failureはtrue requesterだけへthrowし、既存false lease/pumpは維持する。 +- `includeTurns` はsubscriber visibility filterではなくupstream hydrationのminimum hintである。false subscriberも同じchat graphを観測し、別subscriberがtrueへupgradeした後はturn snapshot/updateを受け取り得る。turn非表示はpresentation consumerが選び、owner内でsubscriber別のsemantic graphを作らない。 +- `observe` はmodel-context isolation上でsubscriber queueを登録し、現在のimmutable full projectionをcaptureして`.snapshot(..., reason: .initial)`をenqueueするまでsuspensionしない。その後にだけdelta fan-outを許す。handle returnからiterator開始までのupdateも同queueへ溜まる。`observation.chat` はsemantic action/identity ownerへのhandleでありpresentation baselineではない。consumerは最初のsnapshot eventからrenderし、続くupdateをそのimmutable baselineへ順に適用するため、live graphの先行mutationとdeltaの二重適用を起こさない。1 observationは1 subscriber / 1 iteratorで、2回目のiterator生成はfail-fastする。複数consumerは`observe`を別々に呼ぶ。 +- generationはupstream pumpのstart/rebindごとに増え、新generationはsequence 0の`.generationRestart` snapshotから始まる。sequenceはgeneration内のmodel mutation / global barrier revisionとしてstrictly increaseする。通常updateは直前event + 1でなければならず、snapshotだけが未消費rangeを飛び越えられ、そのsequenceまでのcomplete stateを含む。join時initial snapshotとslow-subscriber overflow snapshotはcurrent revisionをmaterializeするだけでglobal sequenceを進めない。explicit refresh / include-turns upgradeはowner revisionを1進め、既存全subscriberへそれぞれ`.refresh` / `.includeTurnsUpgrade` snapshotを送る。 +- update payloadはその`(generation, sequence)`時点のafter-valueだけでimmutable baselineへ適用できるself-contained domain valueにする。turn insert/updateはfull `CodexTurnSnapshot` + after-index、item insert/updateはtyped `CodexThreadItem` + after-index、status/phaseはnew valueを持ち、text appendだけは直前sequenceのtextへのdeltaである。current-v2 item updateのturn IDはrequiredとし、item indexはそのturn snapshotのitems内after-indexである。remove/text appendは`CodexChatItemLocator`のturn ID + kind + raw item IDで直前baselineのtargetを一意に特定する。同turnでentered/exited review markerが同じraw IDを持てるためraw ID単独には戻さない。consumerがlive `observation.chat`を再読しないと適用できないID-only insert/updateは削除する。insert先に同locatorが存在する、update/remove/text targetが直前baselineにない、indexが範囲外、turnがない場合はすべてcontract violationとする。snapshot compactionはそのcursor以下のupdatesを破棄するため、正当なduplicate removeは生じない。 +- subscriber leaseごとにcapacity 256のbounded queueを作り、`includeTurns` はfalse→trueへ単調upgradeする。257件目ではそのsubscriberのpending eventsをatomicに最新graph + current `(generation, sequence)`の`.snapshot(..., reason: .bufferOverflow)` 1件へcompactし、以後はそのcursorより後だけを後置する。同generation snapshotはpending eventのうち`sequence <= snapshot.sequence`を破棄し、新generation sequence 0 snapshotは全旧generation eventを破棄して、snapshotを必ず次deliveryにする。snapshot + suffixが再度fullなら、より新しいcomplete snapshotで古いsnapshotとsuffixをsupersedeする。別subscriberとupstream pumpは遅いconsumerにblockされず、overflow rangeはdiagnosticへ記録する。 +- 各leaseは`ChatObservationReleaseSignal`の同期sender endpointとownerが発行したnonempty lease IDだけをhandleへ渡す。明示 `close()` はendpointへ`.release(leaseID, acknowledgement)`を同期sendして、そのlease queue finishとrelease acknowledgementをawaitする。last leaseだけがgeneration pump cancel + completion awaitも所有し、残る全relayはpump終了後にfinishする。generation pumpはstructured task group内でupstream childとsingle release-receiver childを同時に待ち、release childがowner isolation上でlease removalをcommitする。receiver handleはownerが保持してclose時にterminate + joinし、stream callback/deinitから`Task { ... }`を生成しない。close中の新規observeはshared close completion後に新generationを開始する。`chatObservationAlreadyActive` とconsumer側のprevious-task awaitを削除する。 +- `CodexChatUpdates` のfailure typeは`Never`である。upstream/connection failureはowner revisionを1進め、typed `.failed(.appServer(...))` phaseを含むcomplete `.snapshot(..., reason: .upstreamFailure)` を各queueの次deliveryへatomic compactしてからnormal finishする。failure-before-first-renderでも`.initial`ではなくこのself-contained failure snapshotを1件受け取る。explicit lease close/caller cancellationはfailure phaseをmodelへ保存せず当該queueだけfinishする。finished generationへの追加eventはproducer contract violationで、新しいretry/rebindは新generation sequence 0から始める。 +- `CodexChatObservation` は作成元のmodel-context isolationへcaller-confinedとし、unchecked `Sendable` にしない。deinitはacknowledgementなしの`.release(leaseID)`を`ChatObservationReleaseSignal`へexactly once同期sendするだけで、actor hopやTaskを作らない。正常系はidempotent `close()` completionをawaitし、deinit releaseはpump joinを保証しないbackstopである。close/deinit raceはsignal側のreleased setでdeduplicateする。 + +### 5.7 Testing surface + +```swift +public enum CodexAppServerTestError: Error, Equatable, LocalizedError, Sendable { + case invalidFixture(String) +} + +public struct CodexAppServerTestItem: Equatable, Sendable { + // Opaque current-v2 wire fixture. Production CodexThreadItem is only its + // domain projection and is never re-encoded to fabricate this value. + public enum CommandSource: Equatable, Sendable { + case agent + case userShell + case unifiedExecStartup + case unifiedExecInteraction + } + public enum CommandStatus: Equatable, Sendable { + case inProgress + case completed + case failed + case declined + } + public enum PatchStatus: Equatable, Sendable { + case inProgress + case completed + case failed + case declined + } + public enum MCPStatus: Equatable, Sendable { + case inProgress + case completed + case failed + } + + public var domainProjection: CodexThreadItem { get } + + public static func agentMessage( + id: String, + text: String, + phase: CodexMessagePhase? = nil + ) throws -> Self + public static func plan(id: String, text: String) throws -> Self + public static func reasoning( + id: String, + summary: [String] = [], + content: [String] = [] + ) throws -> Self + public static func commandExecution( + id: String, + command: String, + cwd: URL, + processID: String? = nil, + source: CommandSource = .agent, + status: CommandStatus, + aggregatedOutput: String? = nil, + exitCode: Int32? = nil, + duration: Duration? = nil + ) throws -> Self + public static func fileChange( + id: String, + changes: [CodexFileUpdateChange], + status: PatchStatus + ) throws -> Self + public static func mcpToolCall( + id: String, + server: String, + tool: String, + status: MCPStatus, + arguments: CodexJSONValue = .object([:]), + resultContent: [CodexJSONValue]? = nil, + structuredContent: CodexJSONValue? = nil, + resultMetadata: CodexJSONValue? = nil, + errorMessage: String? = nil, + duration: Duration? = nil + ) throws -> Self + public static func enteredReviewMode(id: String, review: String) throws -> Self + public static func exitedReviewMode(id: String, review: String) throws -> Self + public static func contextCompaction(id: String) throws -> Self +} + +public struct CodexAppServerTestTurn: Equatable, Sendable { + public var snapshot: CodexTurnSnapshot { get } + public var items: [CodexAppServerTestItem] { get } + public init( + snapshot: CodexTurnSnapshot, + items: [CodexAppServerTestItem] + ) throws + public func replacingItems( + _ items: [CodexAppServerTestItem] + ) throws -> Self +} + +public struct CodexAppServerTestThreadMetadata: Equatable, Sendable { + public enum HistoryMode: Equatable, Sendable { + case legacy + case paginated + } + + public var sessionID: String + public var forkedFromID: CodexThreadID? + public var parentThreadID: CodexThreadID? + public var cliVersion: String + public var source: CodexThreadSourceKind + public var historyMode: HistoryMode + + public init( + sessionID: String, + forkedFromID: CodexThreadID? = nil, + parentThreadID: CodexThreadID? = nil, + cliVersion: String, + source: CodexThreadSourceKind, + historyMode: HistoryMode = .legacy + ) +} + +public struct CodexAppServerTestThreadRuntimeMetadata: Equatable, Sendable { + public enum ApprovalPolicy: Equatable, Sendable { + case unlessTrusted + case onRequest + case granular( + sandboxApproval: Bool, + rules: Bool, + skillApproval: Bool, + requestPermissions: Bool, + mcpElicitations: Bool + ) + case never + } + + public enum ApprovalsReviewer: Equatable, Sendable { + case user + case autoReview + } + + public enum NetworkAccess: Equatable, Sendable { + case restricted + case enabled + } + + public enum SandboxPolicy: Equatable, Sendable { + case dangerFullAccess + case readOnly(networkAccess: Bool) + case externalSandbox(networkAccess: NetworkAccess) + case workspaceWrite( + writableRoots: [URL], + networkAccess: Bool, + excludeTmpdirEnvVar: Bool, + excludeSlashTmp: Bool + ) + } + + public struct ActivePermissionProfile: Equatable, Sendable { + public var id: String + public var extends: String? + public init(id: String, extends: String? = nil) throws + } + + public enum MultiAgentMode: Equatable, Sendable { + case custom(String) + case explicitRequestOnly + case proactive + } + + public var model: String + public var modelProvider: String + public var serviceTier: String? + public var cwd: URL + public var runtimeWorkspaceRoots: [URL] + public var instructionSources: [URL] + public var approvalPolicy: ApprovalPolicy + public var approvalsReviewer: ApprovalsReviewer + public var sandbox: SandboxPolicy + public var activePermissionProfile: ActivePermissionProfile? + public var reasoningEffort: CodexReasoningEffort? + public var multiAgentMode: MultiAgentMode + + public init( + model: String, + modelProvider: String, + serviceTier: String?, + cwd: URL, + runtimeWorkspaceRoots: [URL], + instructionSources: [URL], + approvalPolicy: ApprovalPolicy, + approvalsReviewer: ApprovalsReviewer, + sandbox: SandboxPolicy, + activePermissionProfile: ActivePermissionProfile?, + reasoningEffort: CodexReasoningEffort?, + multiAgentMode: MultiAgentMode + ) throws +} + +public struct CodexAppServerTestStoredThread: Equatable, Sendable { + public var snapshot: CodexThreadSnapshot { get } + public var turns: [CodexAppServerTestTurn] { get } + public var runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata { get } + public var isArchived: Bool { get } + + public init( + snapshot: CodexThreadSnapshot, + turns: [CodexAppServerTestTurn], + metadata: CodexAppServerTestThreadMetadata, + runtimeMetadata: CodexAppServerTestThreadRuntimeMetadata, + isArchived: Bool + ) throws + public func replacingTurns( + _ turns: [CodexAppServerTestTurn] + ) throws -> Self +} + +public struct CodexAppServerTestThreadPage: Equatable, Sendable { + public var threads: [CodexAppServerTestStoredThread] + public var nextCursor: String? + public var backwardsCursor: String? + public init( + threads: [CodexAppServerTestStoredThread], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) +} + +public struct CodexAppServerTestTurnPage: Equatable, Sendable { + public var turns: [CodexAppServerTestTurn] + public var nextCursor: String? + public var backwardsCursor: String? + public init( + turns: [CodexAppServerTestTurn], + nextCursor: String? = nil, + backwardsCursor: String? = nil + ) +} + +public struct CodexAppServerTestModel: Equatable, Sendable { + public enum InputModality: Equatable, Sendable { + case text + case image + } + + public struct UpgradeInfo: Equatable, Sendable { + public var model: String + public var upgradeCopy: String? + public var modelLink: String? + public var migrationMarkdown: String? + public init( + model: String, + upgradeCopy: String? = nil, + modelLink: String? = nil, + migrationMarkdown: String? = nil + ) throws + } + + public struct AvailabilityNUX: Equatable, Sendable { + public var message: String + public init(message: String) + } + + public struct ServiceTier: Equatable, Sendable { + public var id: String + public var name: String + public var description: String + public init(id: String, name: String, description: String) throws + } + + public var domainProjection: CodexModel { get } + + public init( + id: String, + model: String, + upgrade: String?, + upgradeInfo: UpgradeInfo?, + availabilityNUX: AvailabilityNUX?, + displayName: String, + description: String, + hidden: Bool, + supportedReasoningEfforts: [CodexModel.ReasoningOption], + defaultReasoningEffort: CodexReasoningEffort, + inputModalities: [InputModality], + supportsPersonality: Bool, + additionalSpeedTiers: [String], + serviceTiers: [ServiceTier], + defaultServiceTier: String?, + isDefault: Bool + ) throws +} + +public struct CodexAppServerTestModelPage: Equatable, Sendable { + public var models: [CodexAppServerTestModel] + public var nextCursor: String? + public init( + models: [CodexAppServerTestModel], + nextCursor: String? = nil + ) +} + +public enum CodexAppServerTestBedrockCredentialSource: Equatable, Sendable { + case codexManaged + case awsManaged +} + +public struct CodexAppServerTestAccount: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case apiKey + case chatGPT(email: String?, planType: CodexAppServerTestPlanType) + case amazonBedrock( + credentialSource: CodexAppServerTestBedrockCredentialSource + ) + } + + public var domainProjection: CodexAccount { get } + public init(kind: Kind) throws +} + +public struct CodexAppServerTestRateLimitSnapshot: Equatable, Sendable { + public enum ReachedType: Equatable, Sendable { + case rateLimitReached + case workspaceOwnerCreditsDepleted + case workspaceMemberCreditsDepleted + case workspaceOwnerUsageLimitReached + case workspaceMemberUsageLimitReached + } + + public struct Window: Equatable, Sendable { + public var usedPercent: Int32 + public var windowDurationMinutes: Int64? + public var resetsAtUnixSeconds: Int64? + public init( + usedPercent: Int32, + windowDurationMinutes: Int64?, + resetsAtUnixSeconds: Int64? + ) + } + + public struct Credits: Equatable, Sendable { + public var hasCredits: Bool + public var unlimited: Bool + public var balance: String? + public init(hasCredits: Bool, unlimited: Bool, balance: String?) + } + + public struct SpendControl: Equatable, Sendable { + public var limit: String + public var used: String + public var remainingPercent: Int32 + public var resetsAtUnixSeconds: Int64 + public init( + limit: String, + used: String, + remainingPercent: Int32, + resetsAtUnixSeconds: Int64 + ) throws + } + + public var limitID: String? + public var limitName: String? + public var primary: Window? + public var secondary: Window? + public var credits: Credits? + public var individualLimit: SpendControl? + public var planType: CodexAppServerTestPlanType? + public var reachedType: ReachedType? + + public init( + limitID: String?, + limitName: String?, + primary: Window?, + secondary: Window?, + credits: Credits?, + individualLimit: SpendControl?, + planType: CodexAppServerTestPlanType?, + reachedType: ReachedType? + ) throws +} + +public struct CodexAppServerTestRateLimitsResponse: Equatable, Sendable { + public enum ResetType: Equatable, Sendable { + case codexRateLimits + case unknown + } + + public enum ResetCreditStatus: Equatable, Sendable { + case available + case redeeming + case redeemed + case unknown + } + + public struct ResetCredit: Equatable, Sendable { + public var id: String + public var resetType: ResetType + public var status: ResetCreditStatus + public var grantedAtUnixSeconds: Int64 + public var expiresAtUnixSeconds: Int64? + public var title: String? + public var description: String? + public init( + id: String, + resetType: ResetType, + status: ResetCreditStatus, + grantedAtUnixSeconds: Int64, + expiresAtUnixSeconds: Int64?, + title: String?, + description: String? + ) throws + } + + public struct ResetCreditsSummary: Equatable, Sendable { + public var availableCount: Int64 + public var credits: [ResetCredit]? + public init(availableCount: Int64, credits: [ResetCredit]?) throws + } + + public var domainProjection: CodexRateLimits { get } + + public init( + primarySnapshot: CodexAppServerTestRateLimitSnapshot, + snapshotsByLimitID: [String: CodexAppServerTestRateLimitSnapshot]?, + resetCredits: ResetCreditsSummary? + ) throws +} + +public enum CodexAppServerTestConfigurationLayerSource: Equatable, Sendable { + case mdm(domain: String, key: String) + case system(file: URL) + case enterpriseManaged(id: String, name: String) + case user(file: URL, profile: String?) + case project(dotCodexFolder: URL) + case sessionFlags + case legacyManagedConfigTomlFromFile(file: URL) + case legacyManagedConfigTomlFromMdm +} + +public struct CodexAppServerTestConfigurationLayerMetadata: + Equatable, + Sendable +{ + public var source: CodexAppServerTestConfigurationLayerSource + public var version: String + public init( + source: CodexAppServerTestConfigurationLayerSource, + version: String + ) throws +} + +public struct CodexAppServerTestConfigurationLayer: Equatable, Sendable { + public var metadata: CodexAppServerTestConfigurationLayerMetadata + public var configuration: CodexJSONValue + public var disabledReason: String? + + public init( + metadata: CodexAppServerTestConfigurationLayerMetadata, + configuration: CodexJSONValue, + disabledReason: String? + ) +} + +public struct CodexAppServerTestConfigurationReadResult: Equatable, Sendable { + public var configuration: CodexConfiguration + public var origins: [String: CodexAppServerTestConfigurationLayerMetadata] + public var layers: [CodexAppServerTestConfigurationLayer]? + + public init( + configuration: CodexConfiguration, + origins: [String: CodexAppServerTestConfigurationLayerMetadata], + layers: [CodexAppServerTestConfigurationLayer]? + ) throws +} + +public actor CodexAppServerTestThreadStore { + public init( + threads: [CodexAppServerTestStoredThread] = [], + plannedStarts: [CodexAppServerTestStoredThread] = [] + ) throws + public func enqueueStart( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueFork( + _ fork: CodexAppServerTestStoredThread, + from sourceID: CodexThreadID + ) throws + public func storedThread(id: CodexThreadID) -> CodexAppServerTestStoredThread? + public func upsert(_ thread: CodexAppServerTestStoredThread) + public func remove(id: CodexThreadID) -> CodexAppServerTestStoredThread? +} + +public enum CodexAppServerTestLoginCancellationStatus: Equatable, Sendable { + case canceled + case notFound +} + +public struct CodexAppServerTestConfigurationWriteResult: Equatable, Sendable { + public enum Status: Equatable, Sendable { + case ok + case okOverridden + } + + public struct OverriddenMetadata: Equatable, Sendable { + public var message: String + public var overridingLayer: CodexAppServerTestConfigurationLayerMetadata + public var effectiveValue: CodexJSONValue + public init( + message: String, + overridingLayer: CodexAppServerTestConfigurationLayerMetadata, + effectiveValue: CodexJSONValue + ) throws + } + + public var status: Status + public var version: String + public var fileURL: URL + public var overriddenMetadata: OverriddenMetadata? + + public init( + status: Status, + version: String, + fileURL: URL, + overriddenMetadata: OverriddenMetadata? + ) throws +} + +public enum CodexAppServerTestOperation: Equatable, Sendable { + case initialize + case threadStart + case threadResume + case threadFork + case threadList + case threadRead + case threadTurnsList + case threadArchive + case threadUnarchive + case threadDelete + case threadRename + case threadCompact + case threadRollback + case turnStart + case turnInterrupt + case reviewStart + case modelList + case accountRead + case accountRateLimitsRead + case accountLoginStart + case accountLoginCancel + case accountLogout + case configurationRead + case configurationUpdate +} + +public enum CodexAppServerTestRequest: Equatable, Sendable { + case initialize + case threadStart( + workspace: URL, + instructions: CodexInstructions?, + options: CodexThread.Options + ) + case threadResume(id: CodexThreadID, options: CodexThread.ResumeOptions) + case threadFork(id: CodexThreadID, options: CodexThread.Options) + case threadList(CodexThreadQuery) + case threadRead(id: CodexThreadID, includeTurns: Bool) + case threadTurnsList(threadID: CodexThreadID, query: CodexTurnQuery) + case threadArchive(CodexThreadID) + case threadUnarchive(CodexThreadID) + case threadDelete(CodexThreadID) + case threadRename(id: CodexThreadID, name: String) + case threadCompact(CodexThreadID) + case threadRollback(id: CodexThreadID, numberOfTurns: Int) + case turnStart( + threadID: CodexThreadID, + prompt: CodexPrompt, + options: CodexGenerationOptions + ) + case turnInterrupt(threadID: CodexThreadID, turnID: CodexTurnID) + case reviewStart( + threadID: CodexThreadID, + target: CodexReviewTarget, + delivery: CodexReviewDelivery + ) + case modelList(includeHidden: Bool) + case accountRead(refreshToken: Bool) + case accountRateLimitsRead + case accountLoginStart + case accountLoginCancel(CodexLoginHandle.ID) + case accountLogout + case configurationRead + case configurationUpdate(CodexConfigurationPatch) + + public var operation: CodexAppServerTestOperation { get } +} + +public struct CodexAppServerRecordedRequest: Equatable, Sendable { + public var sequence: UInt64 { get } + public var requestID: Int { get } + public var request: CodexAppServerTestRequest { get } +} + +public actor CodexAppServerTestTransport { + // domain-typed response queue, gates and request recording + public init() + package func enqueueInitialized() throws + public func enqueueThreadStart( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueThreadResume( + _ thread: CodexAppServerTestStoredThread, + initialTurnsPage: CodexAppServerTestTurnPage? = nil + ) throws + public func enqueueThreadFork( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueThreadList(_ page: CodexAppServerTestThreadPage) throws + public func enqueueThreadRead( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueThreadTurns(_ page: CodexAppServerTestTurnPage) throws + public func enqueueThreadUnarchive( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueThreadRollback( + _ thread: CodexAppServerTestStoredThread + ) throws + public func enqueueTurnStart(_ turn: CodexAppServerTestTurn) throws + public func enqueueReviewStart( + _ turn: CodexAppServerTestTurn, + reviewThreadID: CodexThreadID + ) throws + public func enqueueModels(_ page: CodexAppServerTestModelPage) throws + public func enqueueAccount( + _ account: CodexAppServerTestAccount?, + requiresOpenAIAuth: Bool + ) throws + public func enqueueConfiguration( + _ result: CodexAppServerTestConfigurationReadResult + ) throws + public func enqueueRateLimits( + _ response: CodexAppServerTestRateLimitsResponse + ) throws + public func enqueueChatGPTLogin( + loginID: CodexLoginHandle.ID, + authenticationURL: URL + ) throws + public func enqueueChatGPTLoginCancellation( + _ status: CodexAppServerTestLoginCancellationStatus + ) throws + public func enqueueConfigurationWrite( + _ result: CodexAppServerTestConfigurationWriteResult + ) throws + public func enqueueSuccess(for operation: CodexAppServerTestOperation) throws + public func enqueueServerFailure( + _ error: CodexServerError, + for operation: CodexAppServerTestOperation + ) + public func holdNext( + _ operation: CodexAppServerTestOperation, + gate: CodexAppServerTestGate + ) + public func holdNextIgnoringCancellation( + _ operation: CodexAppServerTestOperation, + gate: CodexAppServerTestGate + ) + public func recordedRequests() -> [CodexAppServerRecordedRequest] + public func recordedRequests( + for operation: CodexAppServerTestOperation + ) -> [CodexAppServerRecordedRequest] + public func waitForRequest( + _ operation: CodexAppServerTestOperation, + count: Int = 1 + ) async throws + public func waitForRequestCount(_ count: Int) async throws + public func failConnection(_ failure: CodexTransportFailure) async + public func close() async + + package func enqueue( + _ response: Response, + forMethod method: String + ) throws + package func enqueueRawResponseForTesting(method: String, payload: Data) + package func handleRawForTesting( + method: String, + handler: @escaping @Sendable (Data) async throws -> Data + ) +} + +public final class CodexAppServerTestGate: Sendable { + public init() + public func wait() async throws + public func open() + public func close() + package func waitIgnoringCancellation() async throws +} + +public final class CodexAppServerTestDeadlineClock: Sendable { + public init() + public func advance(by duration: Duration) + public func waitForSleeperCount(_ count: Int) async throws + public func close() +} + +public struct CodexFileUpdateChange: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case add + case delete + case update(movePath: String?) + } + + public var path: String + public var kind: Kind + public var diff: String + + public init(path: String, kind: Kind, diff: String) +} + +public enum CodexAppServerTestAuthMode: Equatable, Sendable { + case apiKey + case chatGPT + case chatGPTAuthTokens + case headers + case agentIdentity + case personalAccessToken + case bedrockAPIKey +} + +public enum CodexAppServerTestPlanType: Equatable, Sendable { + case free + case go + case plus + case pro + case proLite + case team + case selfServeBusinessUsageBased + case business + case enterpriseCBPUsageBased + case enterprise + case edu + case unknown +} + +public struct CodexAppServerTestAccountUpdate: Equatable, Sendable { + public var authMode: CodexAppServerTestAuthMode? + public var planType: CodexAppServerTestPlanType? + + public init( + authMode: CodexAppServerTestAuthMode?, + planType: CodexAppServerTestPlanType? + ) +} + +public struct CodexAppServerTestRateLimitsUpdate: Equatable, Sendable { + public var snapshot: CodexAppServerTestRateLimitSnapshot + public init(snapshot: CodexAppServerTestRateLimitSnapshot) +} + +public enum CodexAppServerTestLoginCompletion: Equatable, Sendable { + case succeeded + case failed(message: String?) +} + +public actor CodexAppServerTestNotificationEmitter { + public func emitItemStarted( + threadID: CodexThreadID, + turnID: CodexTurnID, + item: CodexAppServerTestItem + ) async throws + public func emitItemCompleted( + threadID: CodexThreadID, + turnID: CodexTurnID, + item: CodexAppServerTestItem + ) async throws + public func emitAgentMessageDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws + public func emitPlanDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws + public func emitReasoningSummaryPartAdded( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + summaryIndex: Int64 + ) async throws + public func emitReasoningSummaryTextDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + summaryIndex: Int64, + delta: String + ) async throws + public func emitReasoningTextDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + contentIndex: Int64, + delta: String + ) async throws + public func emitMCPToolCallProgress( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + message: String + ) async throws + public func emitTurnCompleted( + threadID: CodexThreadID, + turn: CodexAppServerTestTurn + ) async throws + public func emitCommandExecutionOutputDelta( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + delta: String + ) async throws + public func emitFileChangePatchUpdated( + threadID: CodexThreadID, + turnID: CodexTurnID, + itemID: String, + changes: [CodexFileUpdateChange] + ) async throws + public func emitThreadStatusChanged( + threadID: CodexThreadID, + status: CodexThreadStatus + ) async throws + public func emitError( + threadID: CodexThreadID, + turnID: CodexTurnID, + error: CodexTurnError, + willRetry: Bool + ) async throws + public func emitAccountChanged( + _ update: CodexAppServerTestAccountUpdate + ) async throws + public func emitRateLimitsUpdated( + _ update: CodexAppServerTestRateLimitsUpdate + ) async throws + public func emitLoginCompleted( + loginID: CodexLoginHandle.ID, + completion: CodexAppServerTestLoginCompletion + ) async throws + package func emitRawNotificationForTesting( + method: String, + payload: Data + ) async throws +} + +package enum CodexAppServerTestInjectedRequestOutcome: Sendable { + package enum NoResponseReason: Sendable { + case resolvedNotification + case connectionClosed + } + + case response(CodexAppServerRequestResolution) + case jsonRPCError(code: Int, message: String) + case noResponse(NoResponseReason) +} + +package actor CodexAppServerTestServerRequestInjector { + package func inject( + _ request: CodexAppServerRequest, + requestID: CodexServerRequestID? = nil + ) async throws + -> CodexAppServerTestInjectedRequestOutcome + package func emitResolved( + requestID: CodexServerRequestID, + threadID: CodexThreadID + ) async throws +} + +public struct CodexAppServerTestRuntime: Sendable { + public let server: CodexAppServer + public let transport: CodexAppServerTestTransport + public let threadStore: CodexAppServerTestThreadStore? + public let notificationEmitter: CodexAppServerTestNotificationEmitter + package let serverRequestInjector: CodexAppServerTestServerRequestInjector + public let deadlineClock: CodexAppServerTestDeadlineClock? + + public func close() async + + public static func start( + transport: CodexAppServerTestTransport = .init(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> Self + public static func start( + threadStore: CodexAppServerTestThreadStore, + transport: CodexAppServerTestTransport = .init(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> Self + public static func start( + threads: [CodexAppServerTestStoredThread], + transport: CodexAppServerTestTransport = .init(), + configuration: CodexAppServer.Configuration = .init(), + deadlineClock: CodexAppServerTestDeadlineClock? = nil + ) async throws -> Self +} +``` + +`CodexFileUpdateChange` はproduction `CodexAppServerKit` のdomain valueとして宣言し、Testingだけの複製にしない。それ以外の `ThreadStore` / `Transport` / `NotificationEmitter` / `ServerRequestInjector` は `CodexAppServerKitTesting` target の別 owner filesへ分ける。runtimeにmechanical forwarding methodsは置かず、実在external test consumerが使うstore/transport/emitterだけをpublic owner valueとして公開し、server-request injectorはCodexKit package contract testsだけにする。response queue/gate/recordingを使うlow-level testsは`transport`、archive-aware list/mutation testsは`threadStore`へ直接アクセスする。`CodexThreadSnapshot` 自体へtest-only archive flagを足さず、`CodexAppServerTestStoredThread` がfixture membershipを所有する。 + +public transport controlはdomain valueとclosed `CodexAppServerTestOperation`だけを受け、method string + arbitrary `Encodable`、JSON string、raw handlerをexternal consumerへ公開しない。`enqueueSuccess` はpinned responseが空objectの`.threadArchive/.threadDelete/.threadRename/.threadCompact/.turnInterrupt/.accountLogout`だけを受理する。`thread/unarchive`、`thread/rollback`、`account/login/cancel`、`config/batchWrite`を含むrequired field付きresponseは各typed enqueue以外でqueueできない。allowlistはpinned response DTO inventoryからpackage exhaustiveness testで固定し、それ以外は`invalidFixture`でfail-fastする。generic `enqueue`、`handle/stub`、`enqueueJSON/stubJSON`、`enqueueEmpty`、raw stream finishはpackageに落とし、CodexKit自身のmalformed/framing testsだけが明示`ForTesting` seamを使う。Host/ReviewUI/previewの既存raw DTO・method string setupは上のtyped methods、authoritative thread store、notification emitterへ全面移行する。 + +recordingもraw method/Data/`decodeParams`をpublicにしない。ASK内package shared request codecがwire envelopeと同じcanonical inputからpackage semantic `RecordedOperation`を生成し、Testing targetが1回だけclosed `CodexAppServerTestRequest`へmapする。`operation`はrequest caseからderiveして二重stateを持たない。external testsはtyped associated valuesをassertし、raw payloadと`CodexAppServerRecordedNotification`はCodexKit package codec testsだけに落とす。production targetはTesting public typeへ依存しない。 + +全 `start` overloadはproductionと同じ `CodexAppServer.Configuration` を受け、custom server-request handler、current-time wall clock、deadlinesをin-memory connectionへそのまま注入する。Testing側の別handler/default policyを作らない。deadline race testsはpublic manual `CodexAppServerTestDeadlineClock` を渡し、sleeper registrationを待ってから`advance`する。nilならliveと同じcontinuous clockを使う。 + +`CodexAppServerTestRuntime.start`はinitialize response + initialized handshakeをproductionと同じcodecで自動完了してからserverを返すため、external consumerが`enqueueInitialized`する必要はない。このcontrolはhandshake malformed/deadlineを検証するCodexKit package testsだけへ落とす。 + +Gate/clockはlock-protected state machineとして実装し、continuationをexactly onceだけresumeする。`wait()` / sleeper / transport waitはcancellation handlerから同期的にwaiter tokenをremove + `CancellationError` resumeするため、actor hop用のownerless Taskを作らない。package `CodexAppServerTestWaiterToken` がlock下のpending/resumed bitだけを所有し、transport actorはtoken collectionを各operationでpruneしexplicit `close()`でdrainする。`waitIgnoringCancellation` はtransportのlate-response contract testだけが使い、そのtest ownerがgate `open()`または`close()`を必ず呼ぶ。runtime `close()` はserver full close → transport/gates waiters drain → manual clock sleepers drainの順をawait/実行し、deinit cleanupをprimaryにしない。 + +typed emitterはshared package current-v2 wire DTOだけを構築し、item started/completed、agent/plan/reasoning/command/file/MCP delta、turn terminal、typed `error { willRetry }`、thread status、account invalidation/rate update、ID-correlated login completionをexternal testsへ提供する。account updateはpinned auth mode/planのclosed enumsを必須payloadとして受け、login success readiness fixtureは`authMode: .chatGPT`、signed-out/unavailable fixtureはexplicit nilを送る。無引数の暗黙nil/nil emitterは作らない。pinned upstream notification inventoryにない`item/updated`はtyped emitterへ追加せず、DataKitのsemantic `.itemUpdated`はstarted/delta/completed reducerまたはsnapshot差分からだけ生成する。 + +production `CodexThreadItem` / `CodexTurnSnapshot` / `CodexThreadSnapshot` はconsumer向けdomain projectionであり、wire-isomorphicとは扱わない。Testing targetだけがopaque `CodexAppServerTestItem` / `CodexAppServerTestTurn` / `CodexAppServerTestStoredThread`内にpackage current-v2 DTOを保持する。公開factoryはPreview/Host fixtureが実際に生成するagent message、plan、reasoning、command、file change、MCP tool call、review marker、context compactionだけをtypedに提供し、各factoryが省略可能なupstream fieldを明示的な`nil`/emptyとして所有する。HookPrompt、UserMessageの`clientId + [UserInput]`、AgentMessageの`memoryCitation`、DynamicToolCall、CollabAgentToolCall、SubAgentActivity、WebSearch、ImageView、Sleep、ImageGenerationを含むpinned全item variantはASK package codec testsがcanonical DTOを直接encode/decodeしてexhaustive round-tripする。未提供variantを`CodexThreadItem`や`rawPayload`から逆生成するpublic escape hatchは作らない。 + +production mapper自体もpinned全`ThreadItem` caseをexhaustive switchする。既存consumerがsemantic fieldを使うagent/plan/reasoning/command/file/MCP/review/context variantsはtyped `Content`へ投影し、HookPromptやrich UserInput/memory citation等の未採用fieldはwire DTOを保持せず`.unknown(CodexRawItem)` + bounded diagnostic/raw payloadへ明示projectする。これはdecoder default branchではなくcaseごとの意図的なlossy domain projectionである。将来そのfieldを使う実consumerが現れた場合はproduction content APIをdesign gateで追加し、Testing fixtureをwire sourceとして転用しない。 + +`CodexAppServerTestTurn`はopaque DTOとdomain snapshotを同時に構築し、snapshot state/timingとfixture item projectionの一致をinitializerで検証する。`CodexAppServerTestStoredThread`はrequired `sessionId/preview/ephemeral/historyMode/modelProvider/createdAt/updatedAt/status/cwd/cliVersion/source/turns`をsnapshot + explicit metadataから検証してからcanonical DTOを作る。さらに保持する`CodexAppServerTestThreadRuntimeMetadata`がpinned `ThreadStart/Resume/ForkResponse`のrequired `model/modelProvider/cwd/approvalPolicy/approvalsReviewer/sandbox/multiAgentMode`とoptional/defaulted `serviceTier/runtimeWorkspaceRoots/instructionSources/activePermissionProfile/reasoningEffort`をすべて型付きで所有し、thread DTOのmodel provider/cwdと一致することをinitializerで検証する。`cwd/runtimeWorkspaceRoots/instructionSources/writableRoots`はすべて`URL.isFileURL == true`かつabsolute filesystem pathでなければならず、`https:`等のabsolute non-file URLもrejectする。required値がnil、turn/item projectionが不一致、空required ID/model/provider/profile、Testing factoryが表現しないcustom/subagent/unknown sourceなら`invalidFixture`でrejectし、`{}`、空String、epoch、unknown sourceをfabricateしない。optional `forkedFromId/parentThreadId/recencyAt/path/threadSource/agentNickname/agentRole/gitInfo/name`だけは入力がない場合にwireの`null`を使う。queued thread start/resume/fork/list/read/turn-list/unarchive/rollback responseも`CodexAppServerTestStoredThread` / `ThreadPage` / `TurnPage`の保持済みDTOだけをencodeし、production `CodexThreadSnapshot/Page`から逆生成しない。resumeの`initialTurnsPage`だけはtyped pageを明示し、review startはpinned required `reviewThreadId`を必須引数にする。`emitTurnCompleted` は保持済みDTOを`{ threadId, turn }`へencodeし、completed/interrupted/failed以外をrejectする。lossy `CodexResponse`やreducer後aggregateからwire payloadを逆生成しない。raw emitterはpackage malformed/future-schema testsだけで使い、connection failureはtransportのtyped `failConnection`で注入する。 + +`CodexAppServerTestConfigurationWriteResult`はpinned `ConfigWriteResponse`を型付きで保持し、`.ok`ならoverride metadata nil、`.okOverridden`ならrequired nonempty message/layer version/effective value付きmetadataを要求する。`fileURL`とlayer file/folder URLも`isFileURL`かつabsolute filesystem pathだけを受理する。login cancellationもpinned `.canceled/.notFound`のclosed enumであり、空successへ縮退させない。これらのTesting valuesは特定responseのcanonical fixtureであって、arbitrary JSON response escape hatchではない。 + +model/account responseもproduction projectionからwireへ戻さない。`CodexAppServerTestModel`はpinned `Model`のrequired description/default reasoning/input modalitiesとupgrade/NUX/personality/additional speed tier/full service-tier/default tierをopaque canonical DTOに保持し、`domainProjection`だけをconsumer assert用に公開する。model/model ID、reasoning raw value、service tier IDとdefault tier referenceを検証し、`CodexModel`で欠落したdescription/name/service-tier metadataを空値で補わない。`CodexAppServerTestModelPage`だけがmodel/list responseを作る。 + +`CodexAppServerTestAccount`もpinned tagged account DTOを所有し、`.chatGPT`はoptional email + **required** typed plan、`.amazonBedrock`はrequired credential source、`.apiKey`はpayloadなしを表現する。production `CodexAccount`のderived ID/label/optional planから逆生成せず、`domainProjection`はshared production mapperの結果だけを返す。`enqueueAccount`はこのopaque fixtureと`requiresOpenAIAuth`を受けるため、ChatGPT planやBedrock credential sourceをfabricateしない。 + +rate-limit response/notificationもopaque `CodexAppServerTestRateLimitSnapshot`をwire sourceにする。required legacy primary snapshot、optional limit-ID map、credits/spend-control/reached type、reset-credit summaryをtyped valuesで保持し、Unix secondsやnullable durationをproduction `Date`/required durationから逆生成しない。`CodexAppServerTestRateLimitsResponse.domainProjection`だけがconsumer-facing merged `CodexRateLimits`を返し、`CodexAppServerTestRateLimitsUpdate`は同じsnapshotをnotification envelopeへ入れる薄いtyped valueである。 + +config/readは`CodexAppServerTestConfigurationReadResult`がrequired originsとoptional full layersを明示し、layer source/path/version/config JSONをtyped owner valuesからencodeする。consumer-needed `CodexConfiguration` 4 fields以外のpinned `Config` optional fieldsはこのfactory contractがすべてwire null、flattened additional mapはemptyと定義し、production valueをgeneric再encodeしない。将来consumerが追加fieldを読む時はこのfixture factoryとproduction domain APIを同じdesign gateで拡張する。config/write override metadataも同じtop-level layer source/metadata valueを使い二つ目のschemaを作らない。 + +server-request injector はdomain requestをshared codecで `(id, method, params)` envelopeへencodeし、test transportのinbound channel → connection-owned registry → configured handler → `respond(to:with:)` のencoded response completionを必ず通す。そのresponse/errorをshared codecで再decodeしたoutcomeとして返し、handler return valueを直接返さない。`emitResolved` はmatching in-flight handlerをcancel + awaitしてinjection waiterを`.noResponse(.resolvedNotification)`、connection closeは`.noResponse(.connectionClosed)`へexactly once完了させる。no-response pathを未完continuationとして残さない。 + +thread store が archived membership、upstream sort/default order、pagination、start/resume/fork/read/turn-list/archive/unarchive/delete/renameを所有する。`thread/start`はrequestだけからrequired ID/session/time/runtime metadataを生成せず、FIFO `plannedStarts` / `enqueueStart`のexplicit stored fixtureを1件consumeして初めてmembershipへinsertする。forkも`enqueueFork(_:from:)`でsource IDとcomplete fork fixtureを事前登録し、request時にsourceの不変性、fixtureの`forkedFromID/parentThreadID`、新required ID、workspace/runtime metadataを検証してinsertする。unstaged start/forkやrequest/fixture mismatchはwire-side typed contract violationで、UUID/current time/epoch/default providerを作らない。resumeは既存stored fixtureのruntime metadataを使い、requested initial pageを同じstored turnsから作る。runtime modeは`.queuedResponses`または`.authoritativeThreadStore`の排他的enumにし、`start(threadStore:)` / `start(threads:)`は後者、`start(transport:)`は前者を選ぶ。store modeでthread-owned operationへtyped transport responseをenqueueした場合は`invalidFixture`でrejectし、queue override/precedenceを作らない。非thread operationのqueue/gate/recordingは同じtransportで併用できる。unstubbed methodはfail-fast、queued/in-flight cancellationは`CancellationError`、late responseは破棄する。DataKitの `.both` testsはactive/archived両store、sort、offset、limitを組み合わせる。 + +Previewのstream fixture更新はopaque turn/threadの`replacingItems/replacingTurns`を使い、hidden canonical metadataを保持したままvalidated new DTOを作ってstoreへ`upsert`してからtyped notificationをemitする。このserialized fixture operationだけがstore snapshotとwire eventの両方を更新し、emitterが暗黙にstoreをmutateする二重ownerやproduction snapshot再encodeを作らない。 + +unstubbed outbound methodはTesting固有errorをpublic SDK callからescapeさせず、test transportが `CodexTransportFailure.contractViolation` を返し、clientがrequest ID/method/purpose付き `.request(... kind: .transport(...))` へmapする。`CodexAppServerTestError.invalidFixture` はemitter/injector/storeを直接誤用したtest control callだけがthrowする。production/testのpublic app-server request error taxonomyは同一である。 + +### 5.8 CodexReviewKit internal contracts + +CodexReviewKit productのpublic entry pointは次だけで、残りのstate/actionはpackage contractである。 + +```swift +@MainActor +@Observable +public final class CodexReviewStore { + public func start(forceRestartIfNeeded: Bool = false) async + public func stop() async +} + +package enum CodexReviewAuthenticationFailure: Error, Equatable, Sendable { + case alreadyInProgress + case accountMutationBlockedByAuthentication + case runtime(message: String) + case urlOpen(URL) + case login(message: String?) + case nonExportableCredentialStore + case persistenceInconsistent(message: String) + case accountCommit(message: String) + case protocolViolation(message: String) +} + +@MainActor +@Observable +package final class CodexReviewAuthModel { + package struct Progress: Equatable, Sendable { + package var title: String + package var detail: String + } + + package enum Phase: Equatable, Sendable { + case signedOut + case signingIn(Progress) + case failed(CodexReviewAuthenticationFailure) + } + + package private(set) var phase: Phase + package var errorMessage: String? { get } + // account/selection members retain their baseline package contract. +} + +package extension CodexReviewStore { + func signIn() async throws + func addAccount() async throws + func cancelAuthentication() async +} +``` + +```swift +package enum ReviewIdentityValidationError: Error, Equatable, Sendable { + case empty(field: String) +} + +package struct ReviewRunID: Codable, Hashable, Sendable { + package let rawValue: String + package init(validating rawValue: String) throws + package init(from decoder: any Decoder) throws + package func encode(to encoder: any Encoder) throws +} + +package struct ReviewAttemptID: Codable, Hashable, Sendable { + package let rawValue: String + package init(validating rawValue: String) throws + package init(from decoder: any Decoder) throws + package func encode(to encoder: any Encoder) throws +} + +package struct ReviewThreadID: Codable, Hashable, Sendable { + package let rawValue: String + package init(validating rawValue: String) throws + package init(from decoder: any Decoder) throws + package func encode(to encoder: any Encoder) throws +} + +package struct ReviewTurnID: Codable, Hashable, Sendable { + package let rawValue: String + package init(validating rawValue: String) throws + package init(from decoder: any Decoder) throws + package func encode(to encoder: any Encoder) throws +} + +package struct NonEmptyReviewOutput: Codable, Hashable, Sendable { + package let rawValue: String + package init(validating rawValue: String) throws + package init(from decoder: any Decoder) throws + package func encode(to encoder: any Encoder) throws +} + +package struct ReviewThreadIdentity: Codable, Hashable, Sendable { + package let sourceThreadID: ReviewThreadID + package let activeTurnThreadID: ReviewThreadID + + package init( + sourceThreadID: ReviewThreadID, + activeTurnThreadID: ReviewThreadID + ) +} + +package struct ReviewAttempt: Codable, Hashable, Sendable { + package let attemptID: ReviewAttemptID + package let threadIdentity: ReviewThreadIdentity + package let turnID: ReviewTurnID + package let model: String? + + package init( + attemptID: ReviewAttemptID, + threadIdentity: ReviewThreadIdentity, + turnID: ReviewTurnID, + model: String? + ) +} + +package struct ReviewTurnFailure: Codable, Hashable, Sendable { + package enum Code: Codable, Hashable, Sendable { + case contextWindowExceeded + case sessionBudgetExceeded + case usageLimitExceeded + case serverOverloaded + case cyberPolicy + case httpConnectionFailed(status: UInt16?) + case responseStreamConnectionFailed(status: UInt16?) + case internalServerError + case unauthorized + case badRequest + case threadRollbackFailed + case sandboxError + case responseStreamDisconnected(status: UInt16?) + case responseTooManyFailedAttempts(status: UInt16?) + case activeTurnNotSteerable(kind: String) + case other + case unknown(rawValue: String) + } + + package let message: String + package let code: Code? + package let additionalDetails: String? + + package init(message: String, code: Code?, additionalDetails: String?) +} + +package enum ReviewBackendConnectionTermination: Codable, Hashable, Sendable { + case closed + case transport(message: String) + case processExited(status: Int32?) +} + +package struct ReviewBackendOperationFailure: Codable, Hashable, Sendable { + package enum Operation: String, Codable, Hashable, Sendable { + case startReview + case interruptReview + case prepareRestart + case restartReview + } + + package enum LaunchKind: String, Codable, Hashable, Sendable { + case executableNotFound + case scaffold + case spawn + } + + package enum RequestKind: Codable, Hashable, Sendable { + case encode + case write + case transport + case server(code: Int, turnFailure: ReviewTurnFailure?) + case invalidResponse(expectedType: String) + case deadlineExceeded + case overloadRetryExhausted( + lastCode: Int, + lastTurnFailure: ReviewTurnFailure?, + attempts: Int + ) + } + + package enum Reason: Codable, Hashable, Sendable { + case launch(LaunchKind) + case request(requestID: Int, method: String, kind: RequestKind) + case connectionTerminated(ReviewBackendConnectionTermination) + case turnDeadlineExceeded(turnID: ReviewTurnID, duration: Duration) + case malformedNotification(method: String) + case reviewRestartUnavailable + } + + package let operation: Operation + package let reason: Reason + package let message: String + + package init(operation: Operation, reason: Reason, message: String) +} + +package enum ReviewBackendFailure: Error, Codable, Hashable, Sendable { + case operation(ReviewBackendOperationFailure) + case missingReviewOutput(turnID: ReviewTurnID) + case outputPublication(ReviewOutputPublicationFailure) + case invalidTerminalStatus(rawStatus: String) + case turnFailed(ReviewTurnFailure) + case interruptedByBackend(message: String?) + case connectionTerminated(ReviewBackendConnectionTermination) + case retentionJournal(message: String) + case connectivityObservationEnded + case prepareRestartCancelledUnexpectedly + case restartCancelledUnexpectedly + case protocolViolation(message: String) +} + +package enum ReviewOutputPublicationFailure: + Error, + Codable, + Hashable, + Sendable +{ + case refreshFailed(turnID: ReviewTurnID, message: String) + case unavailable(turnID: ReviewTurnID) + case empty(turnID: ReviewTurnID) + case mismatched(turnID: ReviewTurnID) +} + +package enum ReviewRunCore: Codable, Sendable, Hashable { + case queued + case startFailed( + endedAt: Date, + failure: ReviewBackendFailure + ) + case cancelledBeforeStart( + endedAt: Date, + cancellation: ReviewCancellation + ) + case running( + attempt: ReviewAttempt, + startedAt: Date + ) + case succeeded( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date + ) + case failed( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date, + failure: ReviewBackendFailure + ) + case cancelled( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date, + cancellation: ReviewCancellation + ) + + package var status: ReviewRunState { get } + package var attempt: ReviewAttempt? { get } +} + +package enum ReviewExecutionPhase: Equatable, Sendable { + case starting + case running(attemptGeneration: UInt64) + case preparingRestart + case waitingForNetwork(since: Date) + case restarting + case cancelling(ReviewCancellation) +} + +package enum ReviewLifecyclePresentation: Equatable, Sendable { + case queued + case starting + case running + case waitingForNetwork(since: Date) + case preparingRestart + case restarting + case cancelling(ReviewCancellation) + case succeeded + case failed(ReviewBackendFailure) + case cancelled(ReviewCancellation) +} + +package struct ReviewRunPresentation: Equatable, Sendable { + package let status: ReviewRunState + package let lifecycle: ReviewLifecyclePresentation + package let isCancellable: Bool + + package init(core: ReviewRunCore, executionPhase: ReviewExecutionPhase?) +} + +package struct ReviewCompletion: Equatable, Sendable { + package let finalReview: NonEmptyReviewOutput + + package init(finalReview: NonEmptyReviewOutput) +} + +package enum ReviewBackendTerminal: Equatable, Sendable { + case completed(ReviewCompletion) + case interrupted(message: String?) + case failed(ReviewBackendFailure) +} + +package struct ReviewCompletionCandidate: Equatable, Sendable { + package let turnID: ReviewTurnID + package let expectedOutput: NonEmptyReviewOutput +} + +package enum ReviewBackendObservedTerminal: Equatable, Sendable { + case completed(ReviewCompletionCandidate) + case interrupted(message: String?) + case failed(ReviewBackendFailure) +} + +package struct BackendReviewAttempt: Sendable { + package let attempt: ReviewAttempt + package let observeTerminal: + @Sendable () async throws -> ReviewBackendObservedTerminal + package let observedTerminalIfKnown: + @Sendable () async -> ReviewBackendObservedTerminal? + package let finalizeTerminal: + @Sendable (ReviewBackendObservedTerminal) async -> ReviewBackendTerminal + + package init( + attempt: ReviewAttempt, + observeTerminal: + @escaping @Sendable () async throws -> ReviewBackendObservedTerminal, + observedTerminalIfKnown: + @escaping @Sendable () async -> ReviewBackendObservedTerminal?, + finalizeTerminal: + @escaping @Sendable (ReviewBackendObservedTerminal) async + -> ReviewBackendTerminal + ) +} +``` + +- `ReviewRunID` / `ReviewAttemptID` / `ReviewThreadID` / `ReviewTurnID` / `NonEmptyReviewOutput`はtrimmed valueが空ならthrowするfailable boundaryで、保存するraw text自体はtrim/normalizeしない。各`init(from:)`はsingle Stringをdecodeした後に必ず同じvalidating initializerを呼び、synthesized `Codable`で検証を迂回しない。SDK ID→CRK mapping、persistence decode、MCP request decodeの3境界でtyped wrapperを構築し、失敗は`.protocolViolation`またはdecode failureとしてsurfaceする。run registry/key、`ReviewAttempt`、`ReviewThreadIdentity`、restart token contextはこれらのtyped IDだけを受ける。`ReviewCompletion`もadapterで`response.transcript.reviewOutputText`を取得後に`NonEmptyReviewOutput`を構築し、nil/empty/whitespace-onlyを`.missingReviewOutput`へmapする。 +- adapter は `CodexReviewIdentity` と `CodexTurnOutcome` を一度だけ CRK typeへmapする。 +- SDK `.completed` は`observeTerminal` / `observedTerminalIfKnown`の共通mapperで`response.transcript.reviewOutputText`から`NonEmptyReviewOutput`を一度だけ構築し、nil/emptyは`.failed(.missingReviewOutput)`、`finalAnswer` fallbackは使わない。valid completedはbarrier前の`ReviewCompletionCandidate(turnID, expectedOutput)`として`ReviewBackendObservedTerminal.completed`を返し、この時点でfinal `.completed`を生成しない。result childは通常observeでもcancellation-time probeでも得たobserved valueを同じ`finalizeTerminal`へexactly once渡す。finalizerだけが`ReviewOutputPublicationBarrier`を実行し、active review thread/turnを`CodexChat.refresh(includeTurns: true)`で**一度だけauthoritative refresh**し、そのrefresh transactionがcommitしたcursorのitemsを同じmodel-context isolationで読む。projection側も別のassistant-last heuristicを持たず、DataKitのchat ownerが構築する`chat.transcript(in: turnID).reviewOutputText`というASKの同じKEEP ownerでoutputを抽出する。projected review outputが存在しnonemptyでexpected raw valueと完全一致した場合だけ`.completed(ReviewCompletion)`を返せる。 +- barrierのrefresh failure、unavailable、empty、mismatchは`finalizeTerminal`がそれぞれtyped `ReviewOutputPublicationFailure`へmapし、terminalを`.failed(.outputPublication(...))`としてstoreへ返す。expected/projected全文はdiagnosticやfailureに複製せず、turn IDとfailure kindだけを保持する。worker cancellationはrefresh waiterを外すが、finalizerは一度開始したbarrierをcancellation-shieldedに完遂し、accepted product cancellation arbiterが最終stateを裁定する。barrier完了前に`.succeeded`をpublishする経路、delay/retry/sleep、stored-core output fallbackは作らない。SDK `CodexTurnError` / status / connection terminationもobserved-terminal mapperで一度だけtransport-independent CRK failureへ変換し、`ReviewRunCore.startFailed/failed`までtyped valueを保つ。`localizedDescription` だけを保存するmailbox terminalは削除する。 +- review backend operation boundaryはthrowしたoperationを保持し、SDK errorを次の表でexhaustiveにmapする。`CodexReviewBackend` 実装がthrowしてよい値は `ReviewBackendFailure` とcaller `CancellationError` だけとし、未知error typeは `.protocolViolation` + diagnosticで表面化させる。 + +| SDK / backend error | CRK mapping | +|---|---| +| `.launch(executableNotFound/scaffold/spawn)` | `.operation(operation, .launch(...), message)` | +| `.request` encode/write/transport/server/invalid/deadline/retry | `.operation(operation, .request(requestID:method:kind:), message)` | +| `.connectionTerminated` thrown by start/interrupt/prepare/restart operation | `.operation(operation, .connectionTerminated(...), message)` | +| `CodexReviewSession.collect()`中にoperation call外で確定したconnection terminal | `.connectionTerminated` | +| `.turnDeadlineExceeded` | `.operation(operation, .turnDeadlineExceeded(...), message)` | +| `.malformedNotification` | `.operation(operation, .malformedNotification(method:), message)` | +| `.reviewRestartUnavailable` | `.operation(operation, .reviewRestartUnavailable, message)` | +| `CancellationError` | failureへ保存せずcaller cancellation / store arbiterへ伝播 | + +- request server/retry mappingはSDK `CodexServerError.turnError` があれば既存 `ReviewTurnFailure` へ変換して`RequestKind`に保持する。CRKへraw JSON dataを持ち込まず、既知code/info/additionalDetailsをStringへ落とさない。 +- direct backend operationのfailureはconnection terminationを含め必ず`ReviewBackendOperationFailure.operation`を保持する。top-level `.connectionTerminated`は既に開始済みの`CodexReviewSession.collect()`がoperation call外のconnection terminalで完了した場合だけに使い、start/interrupt/prepare/restartのどのcall中かを失わない。public review event streamは存在しない。 +- `BackendReviewAttempt.observeTerminal` はadapter registryが保持するSDK `CodexReviewSession`をcaptureし、`collect()`のcached typed outcomeを`ReviewBackendObservedTerminal`へmapするoperationである。`observedTerminalIfKnown`はSDK `terminalOutcomeIfKnown()`から同じgeneration handle stateのcompact SDK terminalをnonblockingに読み、同じobserved mapperへ通すpackage operationで、未知ならnilを返しwire request/collector/barrierを開始しない。このpublic SDK affordanceは別packageのCRK adapterがgeneration stateを推測・mirrorせず読むためだけのread-only操作である。これはparent phase reducerから呼ばず、result child自身がphase cancellationで`CancellationError`を受けたcatch内だけでexactly once呼ぶ。knownなら同じchildがobserved valueをsingle `finalizeTerminal`へ渡し、completed barrierをcancellation-shieldedに完遂して`.backendTerminal`をemitする。unknownの場合だけ`.resultWaitCancelled`をemitする。したがってparent drainはterminal candidateかwaiter-cancelのどちらかを1回受け、adapter側collector Task/single-assignment terminal state/event mailbox/producer bridgeやparent側の二回目refreshを追加しない。product cancellationは別のbackend interrupt→cleanup pathが所有する。SDK sessionがtyped terminalなしでfinishした場合はobserved `.failed(.protocolViolation)`、connectionが先ならobserved `.failed(.connectionTerminated)`とし、empty finishをsuccess/cancelへ補完しない。 +- `observeTerminal`がthrowしてよいのは呼出Task自身の`CancellationError`だけである。SDK/transport/protocol/turn failureはobserved `.failed(ReviewBackendFailure)`へmapし、`finalizeTerminal`はnonthrowingでfinal `.failed`を返す。throwing failureと`.failed`の二重channelを作らない。 +- review content/progressはCodexChat projectionがownerなのでbackend event queueへ複製しない。既存`ReviewBackendEventSession` / `BackendReviewEventMailbox` / `ReviewWorkerInputQueue` / `ReviewWorkerEventSource` / unbounded buffered eventsを削除する。adapter attempt registryはTaskを持たずSDK sessionだけをattempt IDで保持し、interrupt/cleanup/restartのauthorityを維持する。 +- backend startがrequired identity付き`BackendReviewAttempt`を返したら、MainActorの`.running` publicationより**前**に`ReviewThreadRetentionRegistry`がin-memory `pendingOwnership`へrun ID + account/home + identityを同期claimし、その後atomic crash journalへcommitする。restart successもnew attemptをgeneration swapする前にsame claim→durable mergeを行う。commit成功でpending claimをpersisted entryへ置換して初めてattemptをpublishでき、known identityがregistry ownershipより先にvisibleになる経路はない。 +- journal commit failureではattemptを公開せず、pending claimがcleanup authorityを保持したままsame live runtimeでinterrupt + SDK session close→`cleanupReview(identity)`完了までをawaitする。cleanup成功後だけclaimをremoveし、initial attemptは`.startFailed(.retentionJournal)`、restart attemptはcurrent run `.failed(.retentionJournal)`へ進む。cleanupも失敗した場合はclaimを`unpersistedCleanupQuarantine`へ移しHost acceptance gatesを閉じ、current runtime/identityをretainしてnew review/account transitionを拒否する。owner recovery operationはjournal commit再試行または`cleanupReview`再試行のどちらかが成功するまでshared completionを保持し、final store stopもこのcompletionを先にjoinしてruntimeを閉じない。cleanup成功またはdurable journal成功のどちらかでのみquarantineを解除するため、二重failureでもidentityをownerlessにして通常運転へ戻らない。 +- every started attempt exit(completed/failed/interrupted/connection terminal/product cancel/recovery abandonment)は、worker terminal commitの成否に関係なく`defer`相当のstructured finalizationでlive attempt registryからSDK sessionをremoveし、session/generation close completionをawaitしてlive resource countを0にする。exit時は既にcommit済みのjournal entryへlate/recovery identityをmerge/updateするだけで、初回登録をterminalまで遅延しない。registryはtext/snapshotを保持しない。routine Host runtime restart/account switchではthreadをdeleteせず、new `CodexModelContainer`がattempt identityからthread/read + one authoritative refreshでCodexChatをrehydrateする。run recordをstoreが保持中はUI/MCP projection sourceも保持される。 +- product run recordはこのmigrationでもin-memoryで、再起動時にrestoreしない。**published/durably-owned run**のdestructive cleanup authorityはtop-level `CodexReviewStore.stop()`または同じ意味の`resetReviewRuns`だけであり、publication前journal failure artifactのquarantine rollbackだけを別authorityとする。final stopはMCP session/workerをdrainしてnew lookupを拒否→全run IDをresolver/UIからatomic retire→matching current runtimeでretained identitiesをsource-last順に`cleanupReview`→run records + successful journal entries clear→Host runtime close、の順をawaitする。cleanup failureまたは別account transition中でmatching runtimeがないentryはidentity/account-homeだけのdurable orphan tombstoneとして残し、次回startupでvisible runを復元せずmatching home runtime起動時にcleanupしてremoveする。process crash時もjournal entryに対応するrun recordは復元されないため同じorphan recoveryとなる。routine restartではretire/cleanup 0、final stopではsucceeded recordを先にresolverから外すためvisible succeeded + missing projectionを作らない。explicit delete/indefinite retention policyは今回追加しない。 +- SDK `.interrupted` はadapterでcancelledにしない。`.interrupted` のままstore cancellation arbiterへ渡し、product cancellationがpendingなら既存cancellation-winsで`.cancelled`、pendingでなければ`.failed(.interruptedByBackend)`とする。invalid statusもString failureへ落とさない。 +- running attemptのterminal arbitrationはMainActor上で次に固定する。terminalをcommitする瞬間にpending cancellationがあればterminal種別を問わずcancellationが勝ち、backend terminal/failureはdiagnostic correlationだけに残す。 + +| Backend terminal/result | pending cancellationなし | pending cancellationあり | +|---|---|---| +| `.completed(output)` | `.succeeded(attempt, …)` | `.cancelled(attempt, …)`。outputをrun stateへ保存しない | +| `.failed(failure)` | `.failed(attempt, failure)` | `.cancelled(attempt, …)`。failureはdiagnosticのみ | +| `.interrupted(message)` | `.failed(attempt, .interruptedByBackend)` | `.cancelled(attempt, …)` | +| connection termination / protocol failure | `.failed(attempt, typed failure)` | `.cancelled(attempt, …)` | + +- running cancel APIはpendingをsetしてbackend interruptをawaitする。interrupt ackが先に成功すれば`.cancelled`をcommitしてworkerをcancelし、adapter cleanup completionをawaitする。interrupt failureがterminal arbitrationより先ならpendingをclearしてtyped operation failureをthrowし、runはrunningのままにする。その後のterminalは「pendingなし」列で処理する。terminal arbitrationが先ならcancellation-winsをcommitし、後着interrupt failureでterminalを反転しない。cancel success後のcleanup failureもtyped diagnosticへ出すが`.cancelled`を反転しない。 +- `cancelReview` callerはpending commit前にcancellation checkを行う。commit後は`ReviewStoreRuntime`がgenerationごとに1つのcancellation operation Task/shared completionを保持し、同じrunへの後続cancelはjoinする。caller Task cancellationはそのcompletion waiterだけを`CancellationError`で外し、受理済みproduct cancellation、interrupt、cleanupをcancelしない。`stop()`はworkerとcancellation operationの両方をcancel + awaitしてpendingだけ残るstateを作らない。`CodexReviewStore`のisolated deinitはruntimeへsynchronous cancel signalを送るだけでawait/callbackせず、async quiescenceのcontractには数えない。 +- worker/cancellation Task closureはstoreや`ReviewStoreRuntime`をcaptureせず、immutable backend/run/generation valuesとweak `ReviewStoreCommitSink`だけを持つ。sinkは各commit call中だけstoreを一時strong化し、generation/stop guard後にMainActor stateを更新する。これにより`store → runtime → Task ⇢ weak sink → store`となり、dropでdeinit backstopへ到達できる。normal lifecycleは必ず`stop()`をawaitする。 +- `ReviewRunCore.startFailed/failed` associated valueがrun failureの唯一のownerで、`errorMessage` / `lifecycleMessage` は削除し、UI/MCP messageをtyped coreからderiveする。store は`.succeeded`を記録するがfinal review textを保存しない。MCP/UI content ownerはCodexChat projectionである。 +- run recordはbackend `startReview` await中も `.queued` のままにする。attempt未確立の失敗/cancelは `.startFailed/.cancelledBeforeStart` へだけ遷移する。`BackendReviewAttempt` がrequired `ReviewAttempt`とtyped observed-terminal operation/probe/finalizerを返し、retention ownershipをdurable commitした時点でattemptを確立し、pending cancellationがなければ `.running(attempt:startedAt:)` へ1 transactionで遷移する。その後はattempt-required terminal casesへだけ進む。`status` / `attempt` はenumからderiveし、不正なoptional/status/failure直積を表現できない。recoveryもreal attemptを得てから`.running`へ入る。decode欠損はfail-fastし、`"attempt-1"` を作らない。 +- queued中の`cancelReview`はstartup cancellationをexactly onceで記録すると同時に、backend start gateを待たず `.queued → .cancelledBeforeStart` をcommitしてsubscriberへ1回だけ通知する。`cancelledBeforeStart` は「backend attemptが永遠に存在しない」ではなく「product cancellation decisionがattempt publicationより先」を表す。start worker handleはruntime ownerが保持したままcancelし、backendがcancellationを無視してlate attemptを返した場合もfire-and-forgetにせず、worker-local cleanup stateとしてinterrupt + cleanup completionまで所有する。late attemptは`.running/.cancelled`へproduct coreを書き換えず、二重terminalをemitしない。 +- `startReview` awaitからMainActorへ戻った直後をpre-attempt arbitration pointとし、storeは既にcommittedなstartup cancellationとstart resultを一度だけ裁定する。non-cancellation start errorとstartup cancellationが競合した場合もproduct cancellationが勝つ。late interrupt/cleanup failureとlate start failureはtyped diagnosticへ出すが、受理済みcancellationを`.failed`へ反転しない。arbitration後、published `.running` に届いたcancellationだけを通常のattempt-required `.cancelled` pathが所有する。 + +| `startReview` result | arbitration時のstartup cancellation | Required transition / cleanup | +|---|---|---| +| success(required attemptあり) | なし | `.queued → .running(attempt)`、event consumption開始 | +| success(required attemptあり) | あり | product coreは既に`.cancelledBeforeStart`。attemptはworker-local cleanup-onlyとしてinterrupt + cleanupし、core/terminal通知を更新しない | +| typed non-cancellation failure | なし | `.queued → .startFailed(failure)` | +| typed non-cancellation failure | あり | product coreは既に`.cancelledBeforeStart(cancellation)`。start failureはdiagnostic correlationに残すがrun failure ownerにしない | +| `CancellationError` / worker cancelled、attemptなし | なし | `.queued → .cancelledBeforeStart(.system)` | +| `CancellationError` / worker cancelled、attemptなし | あり | `.queued → .cancelledBeforeStart(pending cancellation)` | + +started attempt後はparent workerの次のclosed state/signal familyだけでresult、network、restart、cancelを裁定する。custom mailboxやString phaseを追加しない。 + +```swift +package struct ReviewWorkerState: Sendable { + package struct Outage: Sendable { + package let epoch: UInt64 + package let observedAt: ReviewWorkerClock.Instant + package let presentationDate: Date + } + + package enum LiveNetworkPhase: Sendable { + case satisfied + case pendingOutage( + Outage, + heldConnectionTermination: ReviewBackendConnectionTermination? + ) + } + + package enum Stage: Sendable { + case live( + attempt: BackendReviewAttempt, + network: LiveNetworkPhase + ) + case preparingRestart( + interruptedAttempt: ReviewAttempt, + outage: Outage + ) + case waitingForNetwork( + interruptedAttempt: ReviewAttempt, + outage: Outage, + token: CodexReviewRestartToken, + connectivity: WaitingConnectivity + ) + case restarting( + interruptedAttempt: ReviewAttempt, + outage: Outage, + token: CodexReviewRestartToken + ) + } + + package enum WaitingConnectivity: Sendable { + case unsatisfied(nextSettleGeneration: UInt64) + case settling(generation: UInt64) + } + + package var attemptGeneration: UInt64 + package var nextOutageEpoch: UInt64 + package var stage: Stage +} + +package struct ReviewWorkerConnectivitySnapshot: Sendable { + package enum Connectivity: Sendable { + case satisfied + case outage + } + + package let connectivity: Connectivity + package let observedAt: ReviewWorkerClock.Instant + package let presentationDate: Date +} + +package enum ReviewWorkerSignal: Sendable { + case backendTerminal( + generation: UInt64, + ReviewBackendTerminal + ) + case resultWaitCancelled(generation: UInt64) + case networkSnapshot( + generation: UInt64, + ReviewWorkerConnectivitySnapshot + ) + case networkSourceFinished(generation: UInt64) + case outageDebounceElapsed(generation: UInt64, outageEpoch: UInt64) + case recoverySettleElapsed( + generation: UInt64, + outageEpoch: UInt64, + settleGeneration: UInt64 + ) + case prepareRestartCompleted( + generation: UInt64, + outageEpoch: UInt64, + Result + ) + case prepareRestartCancelled(generation: UInt64, outageEpoch: UInt64) + case restartCompleted( + generation: UInt64, + outageEpoch: UInt64, + Result + ) + case restartCancelled(generation: UInt64, outageEpoch: UInt64) +} +``` + +`Stage`がattempt closure、network、tokenの唯一のsource of truthで、直交する`executionPhase/preparedToken/networkPhase` stored propertiesを作らない。network source adapterはexisting `CodexReviewNetworkStatus.satisfied`だけを`.satisfied`、`.unsatisfied`と`.requiresConnection`の両方を`.outage`へexhaustiveに正規化してからworker signalを作る。raw status名からworkerが別挙動を推測せず、両outage sourceは同じdebounce/recovery contractを通る。`nextOutageEpoch`はsame attemptで短いoutageが復旧した後の次epochを一意にするcounterだけで、network stateを複製しない。`.live(.satisfied)`でoutageを最初に受けたactor turnにcurrent valueをOutageへ移してcounterをincrementし、new attempt generation publish時に1へresetする。phaseごとに新しいstructured task groupを開き、`.live`はresult/network-next/必要なdebounce child、`.preparingRestart`はprepare childだけ、`.waitingForNetwork`はnetwork-next/settle child、`.restarting`はrestart childだけを所有する。各phase group終了時は`cancelAll()`後`group.next() == nil`までdrainする。network sequenceはphaseごとに再購読し、current snapshotを最初にreplayするbuffer-newest-1 contractとする。 + +parentは**child outputだけ**を上のenumへ閉じる。signal受理前後にattempt generationとoutage epochの両方を比較し、old generation/epoch signalはdiagnostic + discardする。accepted product cancellation/stopは外部control signal用mailboxへ流さず、MainActor cancellation arbiterがphaseをcommitしてparent Taskをcancelし、parentのcancellation handlerが下表のstructured cleanupへ入る。network debounce/settleはinjected monotonic `ReviewWorkerClock`を使い、Task.sleep、unbounded queue、actor外mirrorは使わない。 + +phase exit helperは`cancelAll()`後に`group.next()==nil`まで返る**全signalをlocal arrayへ回収**し、tag validation後、current generationの`backendTerminal`を高々1件の`drainedTerminalCandidate`として保持する。2件目はprotocol violationである。candidateがあればexit trigger/debounce/network finish/result-wait cancellationより先に通常terminal arbitrationへ渡し、candidateがなければdrained `resultWaitCancelled`とexit causeを表どおり処理する。parentはdrain後に`observedTerminalIfKnown()`も`finalizeTerminal`も呼ばない。SDK terminalとのlast-moment raceはresult childのCancellationError catchがexactly-once observed recheck + single finalizerを行い、その出力がこのcandidateになるため、terminalをdropせずbarrierを二重実行しない。network/clock signalsがexit線形化後にdrainへ現れてもnew stageへ再適用せずdiagnostic + discardする。 + +terminal × network arbitrationは次で固定する。pending product cancellationがある場合は既存cancellation-wins列を先に適用する。 + +| Current stage / signal | Decision | +|---|---| +| `.live` / completed、turn failure/interrupted、protocol/malformed/output-publication failure | network stateに関係なく直ちにterminal arbitration。network/debounce childをcancel + drain | +| `.live(.satisfied)` / connection termination | typed connection failureを直ちにcommit | +| `.live(.pendingOutage)` / connection termination | pending stateへ1件だけholdしresult child終了を記録。network/debounceだけ継続 | +| `.live(.pendingOutage)` / network becomes satisfied | held connection terminalがあればtyped failure、なければ`.satisfied`へ戻る。短いoutageをrestartしない | +| `.live(.pendingOutage)` / matching outage debounce elapsed | phase groupをcancel +完全drainし、`drainedTerminalCandidate`があればterminalが勝つ。candidateなしでheld connection terminationまたはwaiter cancellationだけなら`BackendReviewAttempt` closureをdropして`.preparingRestart`へ進む | +| `.preparingRestart` / prepare success | old SDK sessionをlive registryからremoveしclose completionをawait、identityをsame run retentionへ登録し、closureを保持せず`.waitingForNetwork(token)`へ進む | +| `.preparingRestart` / prepare failure | pending cancellationがなければtyped failure。cancellationがあればcancellation-wins。token/resourceをownerlessに残さない | +| `.waitingForNetwork` / matching satisfied snapshot + settle elapsed | `.restarting`へ進みrestart childを1つ開始 | +| `.restarting` / restart success | terminal/cancel未commitならgenerationを+1しnew required attemptへatomic swapして`.live(.satisfied)`のnew phase groupを開始。既にcommit済みならlate attemptをinterrupt + releaseしsame run retentionへidentityをmerge | +| `.restarting` / restart failure | CRK workerは自動retryせずtyped failure、token invalidate。implicit success/fallbackを作らない | + +signal reducerは次の**stage × signal exhaustive disposition**を実装する。最初にattempt generation、次にsignalが持つoutage epoch、最後にsettle generationを比較し、currentより古いtagはdiagnostic + discard、currentより新しいtagやcurrent tagなのに下表でimpossibleな組合せは`.protocolViolation`でfail-fastする。parent cancellation/accepted product cancellationが既に線形化済みなら、各stage固有decisionより先にcancellation cleanup表へ進む。表の「phase drain」は同じreducer call内で旧groupを`cancelAll()`して`next()==nil`まで読むlocal exit scopeであり、別stored state/mirror flagを追加しない。 + +| Stage | Signal | Required disposition | +|---|---|---| +| `.live(.satisfied)` | `backendTerminal` | completed/non-connection/connectionを上のterminal表で即commitし、groupをdrain | +| `.live(.satisfied)` | `resultWaitCancelled` | parent cancellationならcancellation cleanup。そうでなければ`.protocolViolation` | +| `.live(.satisfied)` | `networkSnapshot(.satisfied)` | no-op | +| `.live(.satisfied)` | `networkSnapshot(.outage)` | `nextOutageEpoch`をconsume + incrementして`.pendingOutage(outage, held:nil)`へ入り、同じphase groupにdebounce childを1つだけ追加 | +| `.live(.satisfied)` | `networkSourceFinished` | parent cancellationでなければgroupをdrain。`drainedTerminalCandidate`があればそれが勝ち、なければ`.connectivityObservationEnded` | +| `.live(.satisfied)` | debounce/settle/prepare/restart family | old tagならdiscard、current/future tagなら`.protocolViolation` | +| `.live(.pendingOutage)` | non-connection `backendTerminal` | outageに関係なくterminal commit + group drain | +| `.live(.pendingOutage)` | connection `backendTerminal` |最初の1件だけholdしresult child終了を記録。duplicate current terminalは`.protocolViolation` | +| `.live(.pendingOutage)` | `resultWaitCancelled` | matching debounce transitionが開始した同じlocal phase drain内ならexpected child join。parent cancellationならcancellation cleanup。それ以外は`.protocolViolation` | +| `.live(.pendingOutage)` | `networkSnapshot(.outage)` | no-op。outage epoch/debounce deadlineをresetしない | +| `.live(.pendingOutage)` | `networkSnapshot(.satisfied)` | debounce childをcancel + join。held connection terminalがあればtyped failure、なければ`.live(.satisfied)` | +| `.live(.pendingOutage)` | matching `outageDebounceElapsed` | 旧groupを完全drainし、drained terminal candidate→held connection/cancelled waiterの優先順。candidateなしならclosure drop後`.preparingRestart` | +| `.live(.pendingOutage)` | `networkSourceFinished` | groupをdrainし、drained terminal candidate→held connection terminal→`.connectivityObservationEnded`の優先順でcommit | +| `.live(.pendingOutage)` | settle/prepare/restart family | old tagならdiscard、current/future tagなら`.protocolViolation` | +| `.preparingRestart` | matching `prepareRestartCompleted(.success)` | old session close/registry removal/retention mergeをawaitして`.waitingForNetwork(..., .unsatisfied(nextSettleGeneration: 1))` | +| `.preparingRestart` | matching `prepareRestartCompleted(.failure)` | token/resource cleanup後typed failure | +| `.preparingRestart` | matching `prepareRestartCancelled` | parent cancellationならcancellation cleanup、そうでなければ`.prepareRestartCancelledUnexpectedly` | +| `.preparingRestart` | backend/result/network/debounce/settle/restart family | prior live groupはdrained済みなのでold tagだけdiscardし、current/future tagは`.protocolViolation` | +| `.waitingForNetwork(.unsatisfied(nextSettleGeneration: n))` | `networkSnapshot(.outage)` | no-op | +| `.waitingForNetwork(.unsatisfied(nextSettleGeneration: n))` | `networkSnapshot(.satisfied)` | `.settling(generation: n)`へ遷移しsettle childを1つ開始 | +| `.waitingForNetwork(.settling(g))` | `networkSnapshot(.satisfied)` | no-op。settle deadlineをresetしない | +| `.waitingForNetwork(.settling(g))` | `networkSnapshot(.outage)` | settle childをcancel + joinし`.unsatisfied(nextSettleGeneration: g + 1)`へ戻る。late timer `g`はdiscard | +| `.waitingForNetwork(.settling(g))` | matching `recoverySettleElapsed(..., g)` | network groupを完全drainして`.restarting`へ進みrestart childを1つ開始 | +| `.waitingForNetwork` | nonmatching `recoverySettleElapsed` | old settle generationならdiscard、current/future impossibleなら`.protocolViolation` | +| `.waitingForNetwork` | `networkSourceFinished` | parent cancellationでなければtoken invalidate + `.connectivityObservationEnded` terminal | +| `.waitingForNetwork` | backend/result/debounce/prepare/restart family | old tagならdiscard、current/future tagなら`.protocolViolation` | +| `.restarting` | matching `restartCompleted(.success)` | token consume、generation +1、new attemptをatomic publishし`.live(.satisfied)` | +| `.restarting` | matching `restartCompleted(.failure)` | token invalidate後typed failure。auto retry 0 | +| `.restarting` | matching `restartCancelled` | parent cancellationならcancellation cleanup、そうでなければtoken invalidate + `.restartCancelledUnexpectedly` | +| `.restarting` | backend/result/network/debounce/settle/prepare family | prior waiting groupはdrained済みなのでold tagだけdiscardし、current/future tagは`.protocolViolation` | + +`networkSourceFinished`はnormal completionではない。explicit owner close時はparent cancellationが先に線形化するためcleanupへ吸収され、それ以外はconnectivity observability喪失としてrunを失敗させる。repeated outageは最初のepoch/debounceを維持し、settle中の再切断だけがsettle generationを進める。これにより古いtimerがnew satisfied windowを誤ってrestartへ進める経路はない。 + +既存`StaticCodexReviewNetworkMonitor`はinitial snapshotをyield直後にfinishしているため、このcontractへ移行する際に「固定状態がowner cancellationまで継続するsource」へ変更し、通常sequenceをfinishしない。consumer Task cancellationがiteratorを終了させる。`ManualCodexReviewNetworkMonitor.finish()`だけをunexpected-source-finish contract testに使い、preview/static compositionがinitial satisfiedの直後に`.connectivityObservationEnded`になる経路を残さない。 + +`prepareReviewRestart`がold active reviewをinterruptしてtyped acknowledgementを待つため、old result childをliveのままprepareしてはならない。そのintentional `.interrupted`を通常backend terminalとしてproduct failureにするraceを、上記phase group drain→drained terminal candidate arbitration→attempt closure drop→prepareの順序で構造的に除く。prepare開始後はold result consumerを再生成せず、coordinatorだけがold session authorityを持つ。result childがSDK terminalを観測済みならterminal mapping + output publication barrierをcancellation-shieldedに完遂してdrain candidateを返し、network phase transitionでcompleted outputを捨てない。 + +`ReviewExecutionPhase`はdurable `ReviewRunCore`とは別のruntime-only projectionで、storeが`ReviewWorkerState.Stage`とpending cancellationからderiveしpersistenceへencodeしない。`ReviewRunPresentation(core:executionPhase:)`のlegal productは次だけで、それ以外はcontract violationである。 + +| Durable core | Execution phase | Lifecycle | Cancellable | +|---|---|---|:---:| +| `.queued` | `.starting` | `.starting` | ✓ | +| `.running` | `.running(generation)` | `.running` | ✓ | +| `.running` | `.preparingRestart` | `.preparingRestart` | ✓ | +| `.running` | `.waitingForNetwork(since)` | `.waitingForNetwork(since)` | ✓ | +| `.running` | `.restarting` | `.restarting` | ✓ | +| `.queued` / `.running` | `.cancelling(cancellation)` | `.cancelling(cancellation)` | — | +| `.succeeded` | nil | `.succeeded` | — | +| `.startFailed/.failed` | nil | `.failed(failure)` | — | +| `.cancelledBeforeStart/.cancelled` | nil | `.cancelled(cancellation)` | — | + +ReviewUIの文言とMCP status/message JSONはこのtyped lifecycleを各presentation boundaryでmapし、`ReviewRunCore.lifecycleMessage/errorMessage`、別Bool、polling textを保存しない。terminal commitでphaseをnilにする。decode/relaunch中のrunning coreはrecoverable-worker bootstrap phaseを明示して再開するかtyped failureへ遷移し、`.running + nil`を表示上success扱いしない。 + +accepted cancellationのphase別authorityは次である。どのpathも`ReviewStoreRuntime`のshared cancellation completionを後続callerがjoinする。 + +| Worker stage | Cancellation / stop action | +|---|---| +| startup | `.cancelling(cancellation)`→`.cancelledBeforeStart`、worker cancel、late attemptはcleanup-only | +| `.live(.satisfied)` | `.cancelling(cancellation)`をpublishしcurrent attemptへinterrupt。ack/terminal arbitration後phase group drain、session release、same run retention register | +| `.live(.pendingOutage)`、result still live | current attemptへinterruptしphase group drain | +| `.live(.pendingOutage)`、held connection terminalあり | wire interrupt 0、local cancellation commit、phase group drain/session release | +| `.preparingRestart` | `.cancelling(cancellation)`をcommitしてprepare completionへjoin。返ったtokenを即invalidateし、old session/late resource cleanupをawait。prepareをownerlessにcancel/dropしない | +| `.waitingForNetwork` | wire interrupt 0、token invalidate、old session release completionへjoin | +| `.restarting` | cancellation-winsをcommitしcoordinatorへtoken invalidationを要求。in-flight restart completionをawaitし、late replacement attemptがあればinterrupt + release + same run retention merge | +| terminal | wire action 0、cached terminalを維持 | + +`stop()`も`.system` cancellationとして同じpathを走るが、全worker/cancellation/coordinator completionをawaitしてからreturnする。CRK recoveryは1 outageにつきprepare 1回 + restart 1回だけで自動retryしない。coordinatorが許す2回目restartはexplicit public caller retry専用で、new generationがliveになればnew outage epochを開始する。 + +- registry missでresumeしてcancelする経路は削除し、in-memory run registryのinvariant violationとして表面化する。 +- `ReviewChatProjectionLookup` は `CodexReviewMCPServer` targetのpackage typeに置く。そこでだけ `.available(ReviewMCPLogProjection) / .unavailable / .refreshFailed(CodexFetchFailure)` を扱い、coreはMCP/DataKit型へ依存しない。`.unavailable`を正常なnull/absent responseとして返せるのは`.queued/.startFailed/.cancelledBeforeStart/.running/.failed/.cancelled`のpre-outputまたはnon-success stateだけである。`.succeeded`はpublication barrier通過済みなので、後続lookupの`.unavailable` / empty / identity mismatchは`ReviewMCPError.projectionInvariantViolation(runID:)`、refresh failureはtyped MCP errorとし、`finalReview: null`へdowngradeしない。 + +#### MCP session / HTTP lifetime contract + +```swift +package actor MCPReviewSessionRegistry { + package enum Phase { + case open + case closing( + reason: MCPReviewSessionCloseReason, + completion: Task + ) + case closed(MCPReviewSessionCloseReport) + } + + package struct SessionState { + package var phase: Phase + package var members: Set + package var pendingStarts: Set + package var operations: Set + package var cancellationScheduled: Set + package var cancellationFinished: Set + } +} + +package actor MCPHTTPServerLifetime { + package enum Phase { + case idle + case staged(MCPHTTPRuntime) + case accepting(MCPHTTPRuntime) + case stopping(Task) + case stopped + } + + package func activate() // synchronous/nonthrowing admission flip + package func stop() async +} +``` + +HTTP transportが確立したsession identityだけがauthorityである。tool JSONのcaller-supplied `sessionID`は削除し、schema compatibility上残す期間が必要ならtransport sessionと同値であることをassertするだけで別session selectorには使わない。read/list/await/cancelはregistryのsame-session member setへselectorを先にintersectし、cross-session run IDは存在を漏らさず`runNotFound`にする。terminal runもsession closeまではmemberとして保持するためsame sessionから結果を再読できるが、store/CodexChat record自体のlifetime ownerにはならない。 + +`review_start`は (1) `.open`だけが`reserveStart(session)`でreservationを登録、(2) MainActorのnon-suspending `CodexReviewStore.beginReview`がrun record + worker ownershipを確立、(3) `bind(runID:reservation:)`がreservationをmemberへ変換、の3段階である。closeがreserveより先ならstore call 0、bindが先ならcloseがmemberをcancel、reserve後bind前にcloseが線形化した場合はlate runを`.sessionClosed` cleanup-only pathへ渡してcallerへrun IDを公開しない。begin failureもreservationをfinishする。close completionは`pendingStarts`が空になるまでreturnしないため、active snapshot後にlate bindされるorphanを作らない。 + +`open → closing`がlogical closeの線形化点で、以後start/read/list/await/cancel/new operationを非同期cleanup未完でもrejectする。最初のclose callerがshared close Taskを作り、後続callerはjoinする。driverはpending reservationがbindする可能性を含め、未処理memberをbatchごとのstructured task groupで`.sessionClosed` cancellationし、queued/startupは`.cancelledBeforeStart(.sessionClosed)`、running/recoveryは`.cancelled(..., .sessionClosed)`へ確定する。これはuser cancelと別のteardown authorityであり、interrupt failureをdiagnostic reportへ残してもrunをrunningへ戻さない。worker、accepted cancellation operation、late attempt interrupt/cleanup、adapter session release、in-flight MCP operation、pending startをすべてjoinしてから`.closed(report)`へ進む。terminal-before-closeではwire interrupt 0である。 + +closed sessionはHTTP request/transport drain中だけtombstoneとして残し、session context removalと同actor transactionでregistry mapから削除する。storeのterminal recordは残る。session IDをconnection lifetime中に蓄積せず、late packetはHTTP context不在としてrejectする。 + +`MCPHTTPRuntime`はlistener/event-loop group、fork-pinned protocol `Server`、transport、session contexts、request/POST stream/GET stream/heartbeat/cleanup tasksを全て保持する。NIO callbackはTaskを作らずMutex-backed event sinkへ同期sendし、lifetime-owned single pumpだけがactorへ届けてchildを登録する。session contextはsession ID reservationと同じactor turn、**creationの最初のawaitより前**に`initializing(creationToken)`としてmapへ登録し、後からprotocol Server/transport/registry session/streamsをbindする。phaseは`initializing → open → closing(shared completion) → closed`で、initialize response write完了前の通常requestを拒否する。 + +DELETE/timeout/global stopは全てsession contextの1つの`closeSession(reason:)` state machineへ収束する。最初のcallerが (1) contextをclosingへ線形化してnew request/bind/publicationを拒否、registry logical close driverを開始するがまだawaitしない、(2) bound済みper-session protocol Serverのadmission close + transport disconnect、(3) protocol request children cancel/await + receive-loop/pending continuation join、(4) handler `defer`によるoperation lease/start reservation release後にregistry close driverをawait、(5) POST/GET stream/writer/heartbeatとcreation completionをdrain、(6) closed reportを保存してcontextをmapからremove、の順をshared completionで完遂する。DELETE request自身はdrain対象operationから先に外し、domain close後に200を返す。timeoutも同じ順なので、leaseを保持するmethod handlerより先にregistry driverをawaitしない。 + +initializing中のcloseはcreation tokenをcancelするだけで完了扱いにせず、creation taskのstructured cleanupを必ずjoinする。Server/transportがclose線形化後にlate生成された場合、creation taskはcontext phaseを再検証してregistry/sessionへbindせず、response/session IDを公開せず、その場でServer admission close→transport disconnect→request/receive children joinを行って`closeSession` completionへhandoffする。closing後にlate `open` publishする経路はない。global snapshot時に未生成Serverだったsessionもcontext mapに既に存在するため列挙漏れにならない。 + +`MCPHTTPServerLifetime.stop()`はadmission gate同期close→listener/cleanup timer close→context mapのinitializing/open全sessionへ`closeSession(.serverStop)`を開始→そのshared completionsをstructured task groupで全件await→remaining runtime child channels/event-loop group close→handle clear、の順を完遂する。各`closeSession`内部でServer/request child joinがregistry driver awaitより先に行われるため、block中handler childがleaseを所有していても循環待ちしない。session close driverはrequest childから独立してlifetimeが所有し、handler自身はdriver/Server stopをawaitしない。Hostはregistryを直接closeせずこのmethodだけをawaitする。 + +timeoutはinjected monotonic clockを使う。active HTTP request、open POST/GET stream、pending start reservation、nonterminal member runのどれかがある間は`idleSince = nil`でtimeout対象外とし、最後のblockerが消えた瞬間からfull timeout windowを開始する。heartbeatはactivity時刻を更新しないがopen stream自体がblockerである。sweep/new request raceはlifetime actorのadmission順で裁定する。 + +固定中のupstream `modelcontextprotocol/swift-sdk 0.12.1`と2026-07-10時点のofficial mainは`Server.start` receive loopからrequestごとに未保持Taskを作り、`Server.stop()`もmain Taskをcancel後nilにするだけでrequest/pending dispatchをjoinしないため、上記stop contractを実現できない。Phase 0 scopeへ`swift-sdk` forkを追加し、receive-loop request childrenをstructured task groupで所有、`stop()`をadmission close→transport disconnect→child cancel/await→receive-loop join→pending request cleanupのawaitable contractへ修正したcommitをexact revisionでpinする。handler自身は`stop()`を呼ばずexit signalだけ返す。local `.build/checkouts` patchやversion-only判断は成果物にせず、fork commit・upstream diff・CRK `Package.resolved` pinを同じdependency waveで検証する。fork publishはremote state変更なので実装時に別途明示承認を取る。 + +private Host lifecycleは次に固定する。 + +```swift +private enum LoginPurpose: Sendable { + case signIn + case addAccountPreservingActive +} + +private enum LoginRuntime: Sendable { + case borrowedPrimary(CodexAppServer) + case ownedIsolated(IsolatedLoginRuntime) +} + +private enum LoginSessionResult: Sendable { + enum Success: Sendable { + case primaryAuthenticated( + account: CodexAccount, + registry: AccountRegistryReconciliation + ) + case primaryAuthenticationCommittedNeedsRuntimeReconciliation( + diagnostic: AccountRegistryDiagnostic + ) + case accountAdded(CodexAccount) + } + + case succeeded(Success) + case failed(LoginSessionFailure) + case cancelled +} + +private enum LoginSessionFailure: Error, Sendable { + case appServer(CodexAppServerError) + case urlOpen(URL) + case login(message: String?) + case nonExportableCredentialStore + case persistenceInconsistent(message: String) + case accountCommit(message: String) + case protocolViolation(message: String) +} + +private enum LoginTerminationReason: Sendable { + case completed + case failed + case explicitCancel + case runtimeFailure +} + +private enum LoginTerminalDecision: Sendable { + case awaitingSDKWinner(LoginTerminationReason) + case sdkFailed(message: String?) + case primaryAuthenticationCommitted + case addCancellationAcceptedBeforeCommit + case productCommitted(LoginSessionResult.Success) + case nonSDKFailure(LoginSessionFailure) +} + +private enum LoginRootOutcome: Sendable { + case final(LoginSessionResult) + case primaryRuntimeReconciliation(CodexLoginReconciliationReason) +} + +private actor LoginFinalResultCompletion { + func wait() async -> LoginSessionResult + func resolve(_ result: LoginSessionResult) -> Bool +} + +private struct PrimaryAuthenticationReconciliationHandoff: Sendable { + let loginGenerationID: UUID + let mutationLease: AccountMutationLease + let reason: CodexLoginReconciliationReason + let finalResult: LoginFinalResultCompletion +} + +private enum LoginSessionTerminationDisposition: Sendable { + case finalized(LoginSessionResult) + case primaryRuntimeReconciliation( + PrimaryAuthenticationReconciliationHandoff + ) +} + +private struct IsolatedLoginRuntime: Sendable { + let appServer: CodexAppServer + let codexHomeURL: URL + // Factory pins upstream `cli_auth_credentials_store = "file"`. + let authFileURL: URL + func close() async +} + +private struct LoginSessionDependencies: Sendable { + var runtimeFactory: + @MainActor @Sendable (LoginPurpose) async throws -> LoginRuntime + var urlOpener: LoginURLOpener + var accountRegistry: AccountRegistryStore + var clock: LoginSessionClock + var readinessTimeout: Duration = .seconds(5) + var cancellationTimeout: Duration = .seconds(5) +} + +private struct LoginSessionClock: Sendable { + var sleep: @Sendable (Duration) async throws -> Void +} + +private actor LoginOperationState { + enum Phase: Sendable { + case acquiringRuntime + case runtimeBound(LoginRuntime) + case loginPending(LoginRuntime, CodexLoginHandle) + case upstreamSuccessObserved(LoginRuntime, CodexLoginHandle) + case isolatedRuntimeFrozen( + authFileURL: URL, + account: CodexAccount + ) + case productCommitted(LoginSessionResult.Success) + case reconciliationHandedOff + case resourcesTaken + } + + func requestCancellation() -> Phase + func bind(runtime: LoginRuntime) -> Bool + func bind(handle: CodexLoginHandle, runtime: LoginRuntime) -> Bool + func markUpstreamAuthenticated() -> Bool + func markIsolatedRuntimeClosed( + authFileURL: URL, + account: CodexAccount + ) -> Bool + func claimDecision(_ decision: LoginTerminalDecision) -> LoginTerminalDecision + func finalizeResult(_ result: LoginSessionResult) -> LoginSessionResult + func takeResourcesForCleanup() -> LoginCleanupResources +} + +private actor AccountRegistryStore { + func beginAuthenticationMutation() throws -> AccountMutationLease + func reconcilePrimaryAuthentication( + account: CodexAccount?, + lease: AccountMutationLease + ) async -> AccountRegistryReconciliation + func importIsolatedAuthentication( + from authFileURL: URL, + account: CodexAccount, + lease: AccountMutationLease + ) async throws + func finishAuthenticationMutation( + _ lease: AccountMutationLease, + outcome: LoginSessionResult + ) async + func switchAccount(_ key: String) async throws + func removeAccount(_ key: String) async throws + func signOut() async throws + func reorderAccounts(_ keys: [String]) async throws + func updateAccountMetadata(_ update: AccountMetadataUpdate) async throws + func loadAndRecover() async throws -> AccountRegistrySnapshot +} + +@MainActor +private final class LoginSession { + private enum State { + case active + case closing( + decision: LoginTerminalDecision, + completion: Task + ) + case handedOff(PrimaryAuthenticationReconciliationHandoff) + case closed(LoginSessionResult) + } + + private let generationID: UUID + private let purpose: LoginPurpose + private let dependencies: LoginSessionDependencies + private let operationState: LoginOperationState + private var state: State + private var rootTask: Task? + + func requestCancellation() async + func result() async -> LoginSessionResult + func terminationDisposition( + reason: LoginTerminationReason + ) async -> LoginSessionTerminationDisposition + isolated deinit +} + +private typealias LoginURLOpener = @MainActor @Sendable (URL) throws -> Void +``` + +- `.signIn`だけはaccepted `HostRuntimeSession`のprimary `CodexAppServer`を`.borrowedPrimary`として使う。pinned upstreamの`AuthManager` cacheは外部home変更をnew-account/A→Bでreloadしないため、別processでtarget homeを書いて`account(refreshToken:)`で反映する設計は採用しない。SDK handleは`login/completed(success)`後の`account/updated` readinessまで待つため、`.succeeded`後のsame-runtime `account()`はrequired nonnil/new accountでなければprotocol failureである。`.addAccountPreservingActive`だけがunique staging CODEX_HOMEの`.ownedIsolated` runtimeを使う。CodexKit `LocalProcess`のcanonical default argsが`-c cli_auth_credentials_store=\"file\"`を強制し、Host staging factoryはcustom argsを許可しない。success後にcanonical `auth.json`がregular file・nonempty・JSON top-level objectでない場合は`.nonExportableCredentialStore`でregistry commit前に失敗する。private auth schemaをSwiftでdecode/re-encodeせずbyte-for-byte copy + source/destination fingerprint一致だけを検証し、Keyring/Auto/Ephemeralを探索・推測しない。 +- add-account rootはstock login success→same isolated runtimeの`account()`→**isolated app-server full close + process reap**→immutable auth file importの順で進む。runtime close completionより前にsource authをcopy/deleteしない。`AccountRegistryStore.importIsolatedAuthentication`はsourceをregular/nonempty/JSON top-level objectとしてだけ検証し、unique revision fileを0600 + exclusive createでbyte-copy、source/destination fingerprint一致 + file/directory fsync後、account entryがそのrevisionを指しactive keyを現在値のまま保持するnext registryをtemp write/validate/fsyncし、same-filesystem atomic replaceを唯一のproduct commit pointにする。private auth fieldをdecode/re-encodeしない。commit前のfailure/cancel/crashはnew revision/stagingだけをGCしてold revision/registry/selectionを変えない。commit後のcleanup failureはdiagnostic + next-load GCで、successをrollbackしない。 +- `AccountRegistryStore`はlogin helperではなく全account persistence ownerである。registryは`schemaVersion/generation/contentHash/activeAccountKey/entry→immutableRevision`を持ち、load、legacy revision-0 migration、add、switch、remove、sign-out、reorder、metadata/rate refresh、shared `auth.json` activation、orphan GCを同actorへ閉じる。shared-authを変更するmutationは**最初のexternal/disk effectより前**にbefore registry + before shared-auth fingerprint/revision + desired registry/hash + `mayApplyIrreversibleLogout`をdurable journalへwrite/fsyncし、`prepared → sharedAuthApplied → registryCommitted`をatomic replace + file/directory fsyncで進める。active remove/sign-outの`prepared` journalはdesired signed-out stateを既に持つため、upstream logout成功直後〜phase更新前にcrashしても復旧情報を失わない。recoveryはjournal phaseだけでなくdisk registryの`generation + contentHash`とshared auth fingerprintをbefore/desiredへ照合する。reversible mutationはbefore fingerprint/registryならrollback、desiredならforward。`mayApplyIrreversibleLogout`でshared authがmissing/changedならlogout successが記録前に起きた可能性を含め必ずdesired signed-outへforwardし、旧authを復元しない。unknown fingerprint/hashは推測せずtyped persistence-inconsistent stateでfail-fastする。inactive removeはregistry replace後、reorder/metadataはsingle registry replace、revision deletionはcommit後だけである。 +- primary `.signIn`は例外的にupstream SDK successが先にshared authをcommitする。success + readiness後はsame-runtime `account()`からrequired new `CodexAccount`を得て、registry snapshot/write failureでもactive authenticationを旧accountへ偽装rollbackせず`AccountRegistryReconciliation.deferred(diagnostic:)`をsuccess valueに添える。success後account update前のconnection death、nil/non-ChatGPT acknowledgement、またはcancel responseのtransport outcomeが不明ならLoginSessionはfinal success/failureを作らず、mutation lease + reason + exactly-once final-result resolverを`PrimaryAuthenticationReconciliationHandoff`としてHost ownerへ移譲する。Hostの`AccountRuntimeTransitionCoordinator`がold runtimeをstop→new runtime staged start→actual shared auth/account read→registry forward repair→expected account validation→lease release→resolver completionを行う。primary runtimeを残したまま`.cancelled`やrequired accountを推測しない。store load/recoveryもshared auth fingerprintがregistry active revisionと異なる場合、それをexternal/primary-auth commitとしてunique revisionへimportしactive entryをrepairする。したがってno-auth→new、A→Bのどちらもfinal accountはnew required valueで、registry failureは認証failureではなくdurable reconciliation debtとなる。SDK success claim前のdefinite failure/cancelだけがold auth/registryを不変にする。 +- `AccountRegistryStore.beginAuthenticationMutation()`がlogin generation用exclusive leaseを発行し、lease中のswitch/remove/sign-out/reorder/metadata writeと二つ目のloginはtyped `accountMutationBlockedByAuthentication` / `alreadyInProgress`でoperation-locally rejectする。逆に既存account mutation中はlogin startをrejectする。previous active keyはsession生成時にcopyせず、add-account registry transaction開始時にstore isolation内で読むため、後着selectionを上書きしない。rejectionはactive LoginSession/AuthModel phase/selectionを変更しない。leaseはdecision付きtermination completionがeventual result + registry/staging cleanupまで終えた後だけreleaseする。 +- shared authを変えるswitch/active remove/sign-outは`AccountRuntimeTransitionCoordinator`の1 shared operationへ収束する。coordinatorはmutation leaseを取得しHost/MCP operation gateをclose→active review/sessionをtyped system cancellationでdrain→`AccountRegistryStore.prepareSharedAuthMutation(...)`でjournal write/fsync→active remove/sign-outならold primary runtime上のupstream logout/revoke outcomeを確定しjournal/disk fingerprintへ反映→old `HostRuntimeSession.stop(.accountTransitionPreservingRuns)` full completion→prepared transactionのregistry/shared-auth commit→new runtimeをstaged start→same-generation `account()`がswitch target account keyと一致、またはsigned-outならnilであることをvalidate→AuthModel selection/phaseをpublish→gates reopen→lease release、の順を完遂する。explicit logout rejectionかつshared authがbefore fingerprintのままならprepared transactionをabortできるが、response loss/connection deathではdisk fingerprintを照合しmissing/changedならforward recoveryする。primary processをdisk mutation後も生かして旧AuthManager cacheを使う経路はない。 +- primary login reconciliation handoffも同じcoordinatorへ入り、old generationをpreserving stop→committed shared authからnew runtime staged start→actual `.chatGPT` account validation→registry repair→login mutation lease release/final resolver completion、の順で処理する。handoffは新しいaccount mutationを開始せず、LoginSessionから移された既存leaseをexactly once消費する。`.primaryAuthenticated`はnew runtime publication/gate reopenより前にresolveしない。 +- reconciliation成功時だけ`.primaryAuthenticated(required account, registry)`をresolveする。committed auth後のreplacement runtime start/account validation/registry repairが失敗した場合は、before accountをfabricateせずdurable reconciliation debtをfsyncし、Hostをfailed/gates-closedに保ったうえでleaseをreleaseし、coordinatorだけが`.primaryAuthenticationCommittedNeedsRuntimeReconciliation(diagnostic)`をfinal resolverへresolveする。このsuccess variantは「認証effectはrollback不能だがruntimeは未確認」を明示し、通常LoginSession pathやreadiness前には生成できない。次のexplicit Host startがjournalからrepairする。 +- top-level final store stopはcoordinatorと別のruntime stop Taskを開始せず、`.finalShutdownRequested(shared completion)`を同じstate machineへlinearizeする。in-flight account transitionがexternal/shared-auth effect前ならprepared transactionをabortし、既にold runtimeをstopしていればbefore stateからcleanup用runtimeをstaged startする。effect適用後/registry commit後またはprimary-login handoff中ならdesired stateへforward-completeしexpected accountをstaged runtimeでvalidateする。どちらもgate/callbackを再公開せず、成功時はreview retention cleanupに使えるquiescent validated sessionをfinal-shutdown continuationへ渡し、mutation/login leaseをreleaseしてからfinal retirementへ進む。cleanup runtimeを開始/validateできない場合もdurable account reconciliation debtと全retained identitiesをorphan journalへfsyncし、login resolverがあればdebt outcomeをresolve、leaseをreleaseしてresolver/UI retireを続行する。thread cleanupを成功扱いせずjournalを残す。これによりaccount transitionとfinal stopが同じ`HostRuntimeSession`を二重stopせず、commit後stateを推測rollbackせず、precommit abort後にcleanup backendをsilentに失わない。 +- registry commit後のnew runtime start/account validation failureではdisk stateを旧accountへ推測rollbackせず、expected state + failureをdurable reconciliation debtへfsyncしてleaseをreleaseし、Hostをfailed/gates-closedに保つ。new operationはgateで拒否され、次のexplicit startだけがcommitted expected stateからruntimeを再構成してdebtをclearする。commit前failureはold runtimeを必要ならsame registryから再startしselection不変にする。inactive remove/reorder/metadataだけはshared auth/cacheを変えないためruntime restart不要で、atomic registry operation完了時にleaseをreleaseする。forced connection restartは同じexpected-active-account validationを行うがregistry mutationはしない。old generationのaccount eventはnew selectionを上書きしない。 +- `LiveCodexReviewStoreBackend` が`activeLoginSession`とgeneration IDの唯一ownerで、MainActor上で`.starting` sessionとregistry mutation leaseを先にinstallする。cancelはhandle bind前でも`LoginOperationState`のphaseへcommitし、factory/startがcancellationを無視してlate runtime/handleを返した場合はURLを開かずpurpose規則に従ってcancel/closeする。通常resultはsame-generation termination dispositionが`.finalized`になった後、reconciliation handoffではcoordinatorがhandoffを受理してactive slotを`.reconciling(generation, finalResult)`へatomic replaceした後だけLoginSession objectをclearする。旧sessionのlate completionはnew session/reconciliationを消さない。root/sessionは同じoperation stateをretainするが互いをcaptureせず、resource bind/take/handoffはactor上でexactly onceである。 +- first terminalとpurpose-specific commit pointは次で固定する。SDK handleのcompletion/cancel responseは`CodexLoginHandle`のfirst-terminal-winsをそのまま使い、Hostが後から`.cancelled`へ上書きしない。 + +| Purpose / operation phase | Completion vs explicit cancellation winner | +|---|---| +| either purpose、runtime/handle bind前 | cancellation bitが勝つ。late owned runtimeはfull close、borrowed primaryはcloseしない。wire effect前なら`.cancelled` | +| `.signIn`、SDK terminal前 | `handle.result()`と`handle.cancel()`のfirst terminal。cancel ackなら`.cancelled`、login failureなら`.failed` | +| `.signIn`、SDK success後 | primary-auth commit済みなのでsuccessが勝つ。後着cancelはsame handle winnerを返し、account read/registry reconciliationを止めない | +| `.addAccountPreservingActive`、SDK success後〜registry replace前 | product commit前なのでaccepted cancellationが勝つ。isolated runtime close/reap、staged revision削除をawaitしold registry/selection不変 | +| `.addAccountPreservingActive`、registry replace後 | successが勝つ。後着cancelはcleanupへjoinするだけでregistryを戻さない | +| SDK `.failed` vs Host cancel intent | SDK handleのfirst-terminal winnerをそのまま採用し、Host cancel intentで`.cancelled`へ上書きしない | +| URL open failure vs late SDK terminal | handle cancel winnerを先に確定し、late successならpurpose別success path、cancel winnerなら`.urlOpen` failure、SDK failureならそのfailure | +| non-SDK failure vs pre-commit cancellation | `LoginOperationState` actorで先にacceptedされた方。後着factはdiagnosticのみ | + +- root Task closureはsessionをcaptureせず、runtime acquisition → stock login start → one-shot URL open → handle winner → purpose-specific commitをoperation stateだけで実行する。別auth notification Task/channel、challenge mirror、WebSessionを作らない。URL-open failureはhandle cancel winnerをawaitし、owned runtimeをcloseする。Host authorityがroot resultを受けて外側から`terminate`をawaitするため、rootは自分をawaitしない。 +- browser/user operationを待つpre-success `handle.result()`自体は無期限で、explicit cancel/Host stop/connection terminalだけが終了させる。Hostは`loginChatGPT(accountReadinessTimeout: .seconds(5))`でshared readiness deadlineを開始時に設定し、最初のexplicit/termination cancel claimantだけが`handle.cancel(acknowledgementTimeout: .seconds(5))`を呼ぶ。injected monotonic `LoginSessionClock`のfinite 5秒deadlineはSDK success claim**後**のaccount-readiness acknowledgementとcancel acknowledgementだけに掛ける。deadline/cancel wire outcome不明ではprimary sign-inを`.cancelled`にせずHostRuntimeSession restart + actual-account reconciliationへ、isolated addはruntime full close/reap + no registry commitへ進む。testsはmanual clock sleeper registration後にadvanceし、wall clock/Task.sleepを使わない。 +- 最初のtermination callerはoperation stateで現時点の`LoginTerminalDecision`をclaimし、MainActor stateを`.closing(decision:completion:)`へ一度だけ遷移する。SDK success claim後〜required account read/registry reconciliation前のdecisionは`.primaryAuthenticationCommitted`で、final `LoginSessionResult.Success`を同期fabricateしない。shared taskは (1) cancellation intent記録/root signal、(2)必要なSDK handle first-terminal確定、(3)root purpose-specific SDK/account observation完了、(4)readiness subscription finish、(5)`takeResourcesForCleanup()` exactly once、(6)owned isolated runtime full close/reap(borrowed primaryはcloseしない)、(7)staging/uncommitted revision cleanup、までを必ず行う。その後、通常pathは(8)registry commit/reconciliation、(9)`finishAuthenticationMutation` lease release、(10)`finalizeResult`→`.closed(result)`を完遂して`.finalized`を返す。unconfirmed primary authだけは(8)の代わりにlease/final resolverをhandoffへmoveしoperation stateを`.reconciliationHandedOff`、sessionを`.handedOff`にして`.primaryRuntimeReconciliation`を返す。この時点でLoginSessionはHost restartもlease releaseもawaitしない。 + +`result()` waiterはsame final-result completionをawaitするが、Host stop/runtime coordinatorは`terminationDisposition`だけをawaitし、handoff後のfinal resultをold runtime stopより先にawaitしない。coordinatorがstop/start→actual account validation→registry repairを終えた後に`finishAuthenticationMutation`をexactly once呼び、resolverをresolveしてactive reconciliation slotをclearする。これにより`old HostRuntimeSession.stop → LoginSession result → Host restart`の循環はない。explicit cancel、runtime failure、Host stopのconcurrent disposition callersはsame Taskへjoinし、lease deinit releaseやimplicit slot clearは使わない。 +- Swift 6.3 `isolated deinit`は`rootTask?.cancel()`という同期signalだけを行う。actor-isolated `LoginOperationState`やSDK handleへTaskを作ってhopせず、owned process leakは`IsolatedLoginRuntime`内の`ProcessTerminationToken` deinitが別途同期terminateする。async handle cancel、registry lease release、process reapの保証は明示`terminate`だけなのでproduction/testsは必ずawaitする。per-handle result導入によりapp-server-wide auth notification Taskは削除する。 +- Host boundaryはactive session failureをtransport-independent `CodexReviewAuthenticationFailure`へexhaustiveに一度だけmapする。SDK/isolated runtime→`.runtime`、URL→`.urlOpen`、login terminal→`.login`、missing/nonexportable auth file→`.nonExportableCredentialStore`、journal/registry corruption→`.persistenceInconsistent`、transaction failure→`.accountCommit`、concurrent login/mutation→`.alreadyInProgress/.accountMutationBlockedByAuthentication`である。active session自身のfailureだけをAuthModel `.failed`へcommitし、operation-local conflictはthrowだけでphaseを変えない。default `LoginURLOpener` はXcode Documentationで確認した`NSWorkspace.open(_:) -> Bool`をMainActorで1回呼び、falseを`.urlOpen`にする。`CodexReviewNativeAuthentication`、WebSession factory/config、callback metadata、presentation token、composition引数を削除する。 + +Host runtime lifecycleは`HostRuntimeSession`へ閉じる。MainActorでgeneration付きstaging sessionを先にinstallし、app server handshake→model container eager creation→connection/account sequences取得→consumer Tasks install→required bootstrap reads/account validation→visible run retention identitiesのthread/read + authoritative refresh/rebind→visible runに属さないsame-home startup orphan journal cleanup→`MCPHTTPServerLifetime` listener bind + same container/main-context projection/backend injectionの順でstagingする。succeeded runのrebind/projection validation failureはpublication前のstaging failureで、missing outputを公開しない。orphan cleanup failureだけはidentity/account-home journalを保持してbounded diagnosticを出し、visible runをrestoreせず次startup retryへ残す。MCP lifetimeはbind済みでもacceptance gateが`.staged`のためrequestを受理せず、bind/configuration failureはsession-owned rollbackで全staged resourceを逆順drainしてnonnil callbackを0回にする。MainActorでgenerationを再確認した1 transaction内でsession/bootstrap/store stateをactiveへcommitし、`appServerLifecycleHandler(container)`をnonnilで1回呼ぶ。最後にだけMCP acceptance gateを同期的・nonthrowingに`.accepting(generation)`へ開く。gateを通ったrequestは必ずfully publishedなsame-generation backend/model sourceを見る。commit後に失敗し得るbind/initialization workは残さない。 + +accepted generationのlower-level stopはpurposeを`.runtimeRestartPreservingRuns` / `.accountTransitionPreservingRuns` / `.loginReconciliationPreservingRuns` / `.finalStoreShutdownRetiringRuns`に分け、同じshared Taskへ収束する。top-level final stop/account mutation/login reconciliationは先に`AccountRuntimeTransitionCoordinator`で直列化し、同じgenerationへlower-level stopを二重発行しない。MainActorでstateをstoppingにし、Host/MCP acceptance gatesを同期closeしてnew HTTP/review/account operationとlate event commitを拒否し、same generationの`appServerLifecycleHandler(nil)`をexactly once old backend close前に呼ぶ(container自体はinternal drain完了までretain)。続いて (1) **`MCPHTTPServerLifetime.stop()`だけ**を呼んでregistry session cancellation + protocol/HTTP child drain、(2) remaining review workersとaccepted cancellation operationsをdrain、(3) active LoginSessionのtermination dispositionをawaitし、`.finalized`ならcleanup完了、`.primaryRuntimeReconciliation`ならhandoffをcoordinatorへ返してfinal resultはawaitしない、(4) restart coordinatorをinvalidate + returned identitiesをrun retentionへmerge、(5) live adapter registry 0を確認、(6) purposeがfinalならrun resolver retire + retention cleanup/journal update、preservingならcleanup 0、(7) connection/account sequences cancel + consumer Tasks await、(8) app-server full close/reap、(9) container/backend references release、の順で完遂する。handoffを受けたcoordinatorだけがstep 9後にreplacement runtimeをstaged startしてlogin reconciliationを完了する。HostがMCP registryを直接closeせず、session cancellation/retirement cleanupがbackendを必要とするためapp-server closeを前倒ししない。 + +connection/account consumerまたはMCP transport childがterminalを検出した場合はgeneration付きexit signalをMainActor ownerへ渡してreturnし、自分のTaskからstop completionをawaitしない。old generationのlate event/exit/stop completionはnew generationのmodel source/acceptance gateをclearせず、callbackもしない。explicit stop、prior-runtime replacement、runtime death、forced restart、MCP bind failureの全pathが同じstaging rollbackまたはaccepted stop順序へ収束する。 + +Previewは`ReviewMonitorPreviewContentSource`ではなくpreview store backendの`PreviewRuntimeLifetime`がstream/notification Tasksと`CodexAppServerTestRuntime`を所有する。source factoryはstore/backend/lifetimeを同時に組み立て、windowはsourceをretainするだけである。`await store.stop()`はstream/notification cancel + await→observation/subscriber close→TestRuntime full close→container releaseを完了し、source deinitは同期cancel signalだけのbackstopとする。production/preview app terminationは同じstore lifecycleを必ずawaitする。 + +### 5.9 Item origin and granular presentation + +```swift +public struct CodexThreadItem: Identifiable, Equatable, Sendable { + // Kind and Content retain their baseline public cases. + public enum Origin: Hashable, Sendable { + case currentV2Item + case reviewRolloutAssistant + } + + public enum SemanticTarget: Hashable, Sendable { + case exitedReviewMode + } + + public enum SemanticRelation: Hashable, Sendable { + case companionOf(SemanticTarget) + } + + public var id: String + public var kind: Kind + public var content: Content + public private(set) var origin: Origin + public private(set) var semanticRelation: SemanticRelation? + public var rawPayload: Data? + + public init( + id: String, + kind: Kind, + content: Content, + rawPayload: Data? = nil + ) + + public var text: String? { get } + public var message: CodexMessage? { get } + + package init( + id: String, + kind: Kind, + content: Content, + origin: Origin, + semanticRelation: SemanticRelation?, + rawPayload: Data? = nil + ) +} +``` + +- shared current-v2 item mapperは `kind == .agentMessage && id == "review_rollout_assistant"` だけを `.reviewRolloutAssistant` + `.companionOf(.exitedReviewMode)` にし、その他の正常current-v2 itemは `.currentV2Item` + relation nilにする。public initializerもこのmapperへ委譲してorigin/relationをforgeさせず、package persistence/reducerだけがlossless package initializerを使う。relationのscopeは同一turnであり、UUIDのreview marker IDを推測して埋めない。 +- pinned upstreamは1つのreview-exit operationからUUIDの `ExitedReviewMode` と固定ID `review_rollout_assistant` のassistant itemを生成する。ただしこのrelationは「同じoperationのcompanion」であって内容同一を保証しない。review child failure等ではouter turnが`.completed`でもmarker fallbackとassistant interruption messageが異なるため、turn dispositionだけでsuppressしない。 +- exact current UIを維持するため、`ReviewRolloutPresentationPolicy` 1 ownerだけが同一turnのtyped companion/target pairに対してdisplay textをnormalizeし、等しい場合だけassistantをsuppressする。異なる場合とtarget欠損時はassistantを残し、後者はdiagnosticも出す。この比較はmerge/semantic identity/reducerへ使わず、一般itemやreasoningへ適用しない。§13のupstream affordanceが得られたら削除する。 +- `agent_reasoning` はlegacy fan-outで、current app-server v2はcanonical `Reasoning` itemを使う。legacy reasoning origin/relationは追加せずproduction `CodexThreadItem` へ変換しない。異なるreasoning itemはtextが同じでも別物として残す。 +- item deltaは新しいorigin/relationを生成せず、base itemの値を保持して更新する。対応するbase itemがなければmalformed notificationである。relationはmerge identityに含めず、`item/started` → `item/completed` で保持されるmetadataとする。 +- 固定IDは公開schemaではなくpinned upstream実装契約なので、判定はshared mapper 1箇所に閉じ、upstream pin更新時にID/emission contract testを必須にする。trimmed text、arrival order、raw JSON kindからrelationを推測しない。 +- `rawPayload` はdiagnostic/future-schema保存用に残すが、ReviewChatLogUIはdecodeしない。DataKitの `CodexItem` / turn snapshot / live mergeはoriginとrelationをlosslessに保持する。 +- このassignmentのevidenceはpinned upstream `core/src/tasks/review.rs`(review exit pair生成)、`core/src/event_mapping.rs`(fixed ID保持)、`app-server-protocol/src/protocol/v2/item.rs`(AgentMessage変換)、`app-server/src/bespoke_event_handling.rs`(canonical v2 item lifecycle)である。 +- `ReviewTurnPresentationPolicy` はturn内の `.enteredReviewMode/.exitedReviewMode` marker countを所有し、1件以上なら同turnのuser-message blocksを非表示にする。first marker insertでは既存user blocksだけをremove、last marker removalでは保持済みtyped user itemsだけを元の位置へreinsertする。suppression中にuser itemがinsert/updateされてもmodel/indexは更新しblockは作らない。item kind replacementでmarker membershipが変わる場合も同じtransitionを適用し、turn document全体はrebuildしない。 +- presentation ownerはimmutable observation snapshotと`CodexChatItemLocator`→projected block IDsのindexを持ち、次の表どおりevent payloadだけをexact cursor順に適用する。target locatorが直前baselineに見つからない場合はinvariant failureとしてtest/logで表面化し、live graphの再読、silent full reprojection、text-searchを行わない。全置換を許すのは全`CodexChatObservationEvent.Payload.snapshot` reasonだけである。 + +| Observation payload | Projection operation | +|---|---| +| `.snapshot(snapshot, reason: .initial/.refresh/.includeTurnsUpgrade/.generationRestart/.bufferOverflow/.upstreamFailure)` | payload snapshotからdocument/indexをreplaceし、そのcursor以前を破棄 | +| `.update(.turnInserted(snapshot, index:))` | payload turnをafter-indexへinsert。duplicate ID / invalid indexはcontract violation | +| `.update(.turnUpdated(snapshot, index:))` | same ID・same index・same item identity/value listをassertし、turn status/metadataだけreplace。item mutation/moveはitem casesが所有 | +| `.update(.turnRemoved(id:))` | indexed turnと全blocksをremove | +| `.update(.itemInserted(item:turnID:index:))` | payload itemのblocksをturn内after-indexへinsert。first review markerなら同turnのuser blocksだけremove | +| `.update(.itemUpdated(item:turnID:index:))` | payload item blocksをreplaceし、index変化なら同turn内move。marker membership変化時は同turn user blocksだけremove/reinsert | +| `.update(.itemRemoved(locator))` | locatorでindexed item blocksをremove。last review markerなら同turnのretained user itemsだけreinsert | +| `.update(.itemTextAppended(locator,delta:))` | exact prior cursorのlocator対象text blockへdeltaをappend | +| `.update(.phaseChanged(phase))` | `phase.turnID` があればindexed turnのstatus-dependent blocks/metadataだけupdate。nilならthread-level chromeだけupdate | +| `.update(.statusChanged(status))` | thread-level chromeだけupdateし、transcript documentはrebuildしない | + +`phaseChanged` はturn IDを別payloadに重複させず、`.running/.terminal` のassociated valueからderiveする。turn-scoped phaseのtargetがmodel/indexに無ければproducer側のcontract violationであり、「念のため全再投影」するfallbackは作らない。 + +## 6. Consumer code + +### CodexAppServerKit external fixture + +```swift +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit + +@MainActor +func verifyProducts() async throws { + guard + let workspace = fixtureThread.workspace, + let modelProvider = fixtureThread.modelProvider + else { + preconditionFailure("fixture must contain required thread metadata") + } + let runtimeMetadata = try CodexAppServerTestThreadRuntimeMetadata( + model: "gpt-5-codex", + modelProvider: modelProvider, + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .workspaceWrite( + writableRoots: [workspace], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + ), + activePermissionProfile: nil, + reasoningEffort: .high, + multiAgentMode: .explicitRequestOnly + ) + let storedThread = try CodexAppServerTestStoredThread( + snapshot: fixtureThread, + turns: fixtureTurns, + metadata: .init( + sessionID: "fixture-session", + cliVersion: "fixture-cli", + source: .appServer + ), + runtimeMetadata: runtimeMetadata, + isArchived: false + ) + let runtime = try await CodexAppServerTestRuntime.start( + threads: [storedThread] + ) + do { + let container = CodexModelContainer(appServer: runtime.server) + let context = container.mainContext + let chats = try await context.fetch(fixtureDescriptor) + precondition(chats.map(\.id) == [fixtureThread.id]) + } catch { + await runtime.close() + throw error + } + await runtime.close() +} +``` + +Fixture は別 Swift package から3 productsへ依存し、`@testable` なしでcompile/linkし、`CodexAppServerKitTesting` のin-memory transportだけでdeterministic runする。umbrella `CodexKit`、installed `codex`、auth、networkへ依存しない。実processを使うsampleは別の明示opt-in executableとし、environment gateなしにpackage test/acceptanceから起動しない。 + +同じfixtureはTestingのpublic controlsがraw JSONなしで完遂することもcompile/runで固定する。 + +```swift +let transport = CodexAppServerTestTransport() +try await transport.enqueueAccount(nil, requiresOpenAIAuth: true) +let runtime = try await CodexAppServerTestRuntime.start(transport: transport) + +_ = try await runtime.server.account() +let recorded = await transport.recordedRequests(for: .accountRead) +precondition(recorded.count == 1) +guard case .accountRead(refreshToken: false) = recorded[0].request else { + preconditionFailure("unexpected semantic request") +} + +try await runtime.notificationEmitter.emitRateLimitsUpdated( + .init(snapshot: try .init( + limitID: "codex", + limitName: nil, + primary: nil, + secondary: nil, + credits: nil, + individualLimit: nil, + planType: .pro, + reachedType: nil + )) +) +await runtime.close() +``` + +### Host production composition + +```swift +@MainActor +func makeApplicationWindow( + preferences: CodexReviewRuntime.Preferences, + showSettings: @escaping @MainActor () -> Void +) -> (CodexReviewStore, ReviewMonitorWindowController) { + let modelSource = ReviewMonitorCodexModelSource() + let store = CodexReviewStore.makeLiveStore( + runtimePreferences: preferences, + appServerLifecycleHandler: { container in + if let container { + modelSource.install(container: container) + } else { + modelSource.clear() + } + } + ) + let window = ReviewMonitorWindowController( + store: store, + codexModelSource: modelSource, + showSettings: showSettings + ) + return (store, window) +} +``` + +application delegateは起動時に`await store.start(forceRestartIfNeeded:)`、termination時に`await store.stop()`を呼び、window/Hostが別のlifecycle stateを持たない。 + +Host内部のstock login operationはpurposeに応じてprimary runtimeをborrowするかfile-backed isolated runtimeをownし、SDK handleのpost-success account-readiness barrierをそのまま使う。 + +```swift +let loginRuntime = try await dependencies.runtimeFactory(purpose) +let handle = try await loginRuntime.appServer.loginChatGPT( + accountReadinessTimeout: dependencies.readinessTimeout +) +try dependencies.urlOpener(handle.authenticationURL) +switch try await handle.result() { +case .succeeded: + guard + let account = try await loginRuntime.appServer.account(), + account.kind == .chatGPT + else { + return .final(.failed( + .protocolViolation(message: "login succeeded without ChatGPT account") + )) + } + switch (purpose, loginRuntime) { + case (.signIn, .borrowedPrimary): + let reconciliation = await dependencies.accountRegistry + .reconcilePrimaryAuthentication(account: account, lease: lease) + return .final(.succeeded(.primaryAuthenticated( + account: account, + registry: reconciliation + ))) + case (.addAccountPreservingActive, .ownedIsolated(let isolated)): + await isolated.close() // process reap completes before auth file read + try await dependencies.accountRegistry.importIsolatedAuthentication( + from: isolated.authFileURL, + account: account, + lease: lease + ) + return .final(.succeeded(.accountAdded(account))) + default: + return .final(.failed( + .protocolViolation(message: "purpose/runtime mismatch") + )) + } +case .authenticationCommittedNeedsConnectionReconciliation(let reason): + switch purpose { + case .signIn: + return .primaryRuntimeReconciliation(reason) + case .addAccountPreservingActive: + return .final(.failed( + .protocolViolation(message: "isolated login readiness unavailable") + )) + } +case .failed(let message): + return .final(.failed(.login(message: message))) +case .cancelled: + return .final(.cancelled) +} +``` + +`account`はSDKがsuccess後`account/updated` readinessを観測した後にsame runtimeからrequired readする。sign-inのunconfirmed committed stateはHost runtime restart + actual account reconciliationへhandoffし、add-accountではregistry commitしない。URL open、account read、owned runtime close/reap、purpose commitを飛ばすfallbackはない。connection/account sequencesもHost runtime ownerがstored Taskとしてconsumeし、stop時にsequence cancel + Task completionをawaitする。 + +ReviewUIのauthentication actionはthrowing package operationを直接awaitし、operation-local rejectionをcatchする。 + +```swift +@MainActor +func performSignInCommand() async { + do { + try await store.signIn() + } catch let failure as CodexReviewAuthenticationFailure { + authenticationFailurePresenter.present(failure) + } catch { + preconditionFailure("unexpected authentication error: \(error)") + } +} +``` + +active session自身のfailureは`CodexReviewAuthModel.phase.failed`がownerであり、上のpresenterは二重command/account-mutation conflict等のoperation-local throwだけを一回表示する。baselineの`authenticationFailureCount` polling、warning Bool/String mirror、`try?` actionは削除する。 + +### DataKit model actor + +```swift +actor ReviewRepository: CodexModelActor { + nonisolated let modelContainer: CodexModelContainer + nonisolated let modelExecutor: CodexDefaultSerialModelExecutor + + init(container: CodexModelContainer) { + modelContainer = container + modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: container) + } + + func chat(id: CodexThreadID) -> CodexChat? { + modelContext.registeredModel(for: id) + } +} +``` + +concrete executorはこのactor storyに必要であり、generic executor protocolやpublic context getterを外部へ足さない。 + +### CRK adapter before / after + +Before: + +```swift +case .turnCompleted(let response): + if response.status == .interrupted { /* repair */ } + else if response.status?.isFailure == true { /* fail */ } + else if let text = response.transcript.reviewOutputText + ?? response.finalAnswer + ?? response.transcript.finalAnswer { /* complete */ } +``` + +After: + +```swift +let outcome = try await reviewSession.collect() +switch outcome { +case .completed(let response): + let turnID = try ReviewTurnID(validating: response.turnID.rawValue) + guard let rawOutput = response.transcript.reviewOutputText else { + return .failed(.missingReviewOutput(turnID: turnID)) + } + let output = try NonEmptyReviewOutput(validating: rawOutput) + try await outputPublicationBarrier.publish( + expected: output, + attempt: attempt + ) + return .completed(.init(finalReview: output)) +case .interrupted: + return .interrupted(message: nil) +case .failed(let failedTurn): + return .failed(.turnFailed(mapTurnFailure(failedTurn.error))) +case .invalidTerminalStatus(let status, _, _): + return .failed(.invalidTerminalStatus(rawStatus: status)) +} +``` + +このadapterはSDK factをCRK factへ変換するだけで、product lifecycleを決めない。とくに `.interrupted` をcancelled/failedのどちらにするかはpending cancellationを所有するstore arbiterだけが決定する。 + +### DataKit UI + +```swift +@CodexQuery(filter: #Predicate { chat in + chat.workspaceID == selectedWorkspaceID +}) +private var chats + +var body: some View { + if case .failed(let failure) = chats.phase { + QueryFailureView(failure: failure) + } else { + ChatList(chats) + } +} +``` + +明示 predicate は archive scopeを勝手に狭めない。active-only が必要な consumer は `chat.isArchived == false` を書く。 + +### Chat observation + +```swift +let observation = try await chat.observe(includeTurns: true) +let consumer = Task { @MainActor in + for await event in observation.updates { + projection.apply(event) // snapshot replacement or exact-cursor update + } +} + +// view/model teardown owner +consumer.cancel() +await observation.close() +await consumer.value +``` + +consumer Taskとobservation handleは同じview/model ownerがstored propertyとして保持し、teardownでcancel/close/awaitする。fire-and-forgetにしない。CRKは前observation taskの終了を待ってから次を登録する必要はないが、各自のrelease completionはawaitする。 + +### Preview composition + +```swift +let content = ReviewMonitorPreviewContent.makeContentSource() +let window = ReviewMonitorWindowController( + previewContent: content, + showSettings: showSettings +) + +// application/test teardown +await content.store.stop() +``` + +preview contentは同じproduction store/UI data flowを通り、Tools appがpublic `store`だけをidentity checkに使う。Xcode CI targetだけが`prepareForSwiftUIPreviewRendering()`と`appendPreviewChatLogStreamTickForTesting(after:)`を使う。 + +## 7. Access control plan + +これはPhase 2で承認するplanned-public inventoryである。粒度はgenerated Swift interface上のdeclaration familyとする。`KEEP/FREEZE`はbaseline generated interfaceそのもの、`NEW`は§5の全member、`CHANGE`は「baseline public memberを既定で保持し、§5/§7で明示したremove/replace/addを適用した差分」で閉じる。したがって`CHANGE` family内の「baselineのまま」と記したmemberを再列挙しても新しい裁量は生じない。表にない宣言はpublicにしない。別package fixtureは到達可能性の検証手段であって、実在consumerのないAPIをpublicにする理由には使わない。 + +Actionは次で固定する。 + +- `KEEP`: 現行public interfaceを維持する。 +- `CHANGE`: 現行public familyを記載した契約へsource-breakingに変更する。 +- `NEW`: 記載したfamilyを新規publicにする。 +- `PACKAGE`: package以下へ縮小し、外部interfaceから除く。 +- `DELETE`: 宣言と互換経路を削除する。 +- `FREEZE`: Phase 0 non-goalのため現行public interfaceを完全一致で維持する。 + +Phase 4ではこのinventoryを作るのではなく、recursive `public/open` scanとgenerated `.swiftinterface` をこのinventoryへ機械照合する。action変更または未記載publicが必要になった時点で実装を止め、design gateを更新する。 + +### 7.1 `CodexAppServerKit` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `CHANGE` | `CodexAppServer`; nested `Configuration`, `LocalProcess` | public init/close、connection/account streams、thread start/resume/fork/list/archive/unarchive/delete、review start/resume/prepare-restart/restart/discard-one/discard-all/cleanup、model/account/rate/config read-write、stock `loginChatGPT`/logoutだけを残す。discardはretained identityを返すprepared-resource close authorityで、coordinator stateは公開しない。custom server-request handlerはpublic configurationから除く | `CodexReviewAppServer`, `CodexReviewHost`, `CodexDataKit` | +| `NEW` | `CodexAppServer.Configuration.Deadlines` | §5.2のrequest/handshake deadline configuration | Host, Testing | +| `CHANGE` | `CodexThread`; nested `Options`, `ResumeOptions` | ID/workspace/model、`respond`→`CodexTurnOutcome`、review start、read/listTurns、rename/compact/archive/unarchive/delete、`cancelActiveTurn`、`closeConnection`。`streamResponse`、public event/message/transcript/log accessors、public rollbackは除く | `CodexReviewAppServer`, `CodexDataKit` | +| `CHANGE` | `CodexReviewSession` | review/source/turn identity、`collect(timeout:)`, nonwaiting `terminalOutcomeIfKnown()`, `cancel()`, `closeConnection()`だけ。public `AsyncSequence`/progress streamを公開しない | CRK adapter | +| `KEEP` | `CodexReviewTarget`, `CodexReviewDelivery`, `CodexReviewIdentity`, `CodexReviewRestartToken`, `CodexTurnCancellation` | value semanticsと現行public construction/read surfaceを維持 | CRK adapter/restart/cancel path | +| `NEW` | `CodexReviewCleanupFailure`, `CodexReviewCleanupResult` | source-last cleanupのattempt順とfailed deletionをlosslessに返し、failure時のretained identity再試行を可能にする | CRK retention/final-stop cleanup | +| `NEW` | `CodexTurnOutcome`, `CodexFailedTurn` | §5.1のexhaustive terminal value。failed turn initializerはpackage | CRK adapter, DataKit | +| `CHANGE` | `CodexResponse` | responseはidentity/transcript/usage + `startedAt/completedAt/duration` timing。`finalAnswer`, `errorMessage`, status-derived predicatesを削除し、typed failureはoutcomeに置く | CRK adapter, DataKit, ReviewChatLogUI | +| `NEW` | `CodexTurnError`, `CodexErrorInfo` | current-v2 turn failureをlosslessに保持する§5.1 value | CRK adapter, DataKit, ReviewChatLogUI | +| `CHANGE` | `CodexAppServerError` | §5.2のtop-level layered error taxonomy | Host, CRK adapter, DataKit | +| `NEW` | `CodexRequestFailure`, `CodexRequestPurpose`, `CodexServerError`, `CodexTransportFailure`, `CodexLaunchFailure`, `CodexMalformedNotification` | request correlation/raw data/turn errorをlosslessに公開し、`CancellationError`は包まない | Host, CRK adapter, DataKit, Testing controls | +| `NEW` | `CodexConnectionEvent`, `CodexConnectionTermination`, `CodexConnectionEvents`, `CodexDiagnostic`, `CodexRetryDiagnostic`, `CodexDeprecationNotice` | §5.4のbounded diagnostic + compact first-terminal sequence | Host runtime lifecycle/recovery | +| `CHANGE` | `CodexAccountEvent` | account invalidation、merged rate limits、malformed/unknownだけ。login completionとruntime-death side channelは除く | Host account model | +| `NEW` | `CodexAccountEvents` | §5.4のcancel-aware account sequence | Host account model | +| `CHANGE` | `CodexLoginHandle`/`ID` | one-shot authentication URL、typed result/cancel、close。ChatGPT stock loginだけ | Host `LoginSession` | +| `NEW` | `CodexLoginOutcome`, `CodexLoginReconciliationReason` | succeeded / committed-needs-reconciliation / failed / cancelled terminal value。browser waitは無期限、success readiness/cancel ackだけoptional deadline | Host `LoginSession` | +| `KEEP` | `CodexPrompt`/`Part`/`CodexPromptBuilder`, `CodexInstructions`/`CodexInstructionsBuilder`, `CodexJSONValue` | current ergonomic construction/conformance surface | CRK adapter and app consumers | +| `KEEP` | `CodexSandbox`, `CodexThreadPermissions`, `CodexApprovalMode`, `CodexReasoningEffort`, `CodexReasoningSummary`, `CodexPersonality`, `CodexThreadStartSource`, `CodexThreadSource` | current-v2 prompt/thread option values | Host/CRK/DataKit | +| `CHANGE` | `CodexGenerationOptions` | obsolete `transcriptErrorHandlingPolicy`を削除し、current-v2 generation optionsだけを保持 | Host/CRK/DataKit | +| `KEEP` | `CodexThreadID`, `CodexTurnID`, `CodexThreadQuery`, `CodexTurnQuery`, `CodexThreadPage`, `CodexTurnPage`, `CodexSortDirection`, `CodexThreadSortKey`, `CodexThreadSourceKind` | identity/query/page values。archive scopeはconsumer predicate/query planで明示 | DataKit, CRK, Testing | +| `KEEP` | `CodexThreadSnapshot`, `CodexTurnItemsLoadState`, `CodexThreadActiveFlag` | current snapshot/load/active-flag interfaceを維持 | DataKit, CRK, ReviewUI, Testing | +| `CHANGE` | `CodexTurnSnapshot`, `CodexThreadStatus`, `CodexTurnStatus` | nested legal `State`でfailed errorをrequired化しtimingを保持。turn statusは`inProgress/completed/interrupted/failed/unknown`だけ、thread statusは`closed` aliasなしでclassifier helperを持たない | DataKit, CRK, ReviewUI, Testing | +| `CHANGE` | `CodexThreadItem` and nested `Kind`, `Content`; existing target/action/source values | current-v2 wireから作るconsumer domain projection。baseline content ergonomicsを保ちorigin/relationへ接続するがwire DTOとして再encodeしない | DataKit, ReviewChatLogUI | +| `NEW` | `CodexThreadItem.Origin`, `.SemanticTarget`, `.SemanticRelation`; `CodexFileUpdateChange`/`Kind` | origin/relationとfile patchのdomain facts | DataKit, ReviewChatLogUI | +| `KEEP` | `CodexTranscript`, `CodexReasoning`, `CodexMessage`/`Role`, top-level `CodexMessagePhase`, `CodexCommand`/`Source`/`Action`, `CodexFileChange`, `CodexToolCall`, `CodexRawItem`, `CodexTokenUsage`, `CodexRawNotification` | current value construction/read surfaceを維持 | CRK, DataKit, ReviewChatLogUI | +| `PACKAGE` | `CodexMessageDelta`, `CodexReasoningPart`/`Kind`, `CodexReasoningDelta` | package event/reducer projectionだけが使用し、external consumer storyなし | AppServerKit/DataKit implementation | +| `KEEP` | `CodexConfiguration`, `CodexConfigurationPatch`, `CodexRateLimits`, `CodexRateLimitWindow`, `CodexModel`/reasoning option, `CodexAccount`/`Kind` | current public value surface。sparse rate mergeはtype-owned `merging`へ集約 | Host settings/account/model UI | +| `PACKAGE` | `CodexTurn`, `CodexResponseStream`, `CodexTurnEvent`, `CodexReviewEvent`, `CodexReviewProgress`, `CodexThreadEvent`, `CodexThreadEventSequence`, `CodexThreadMessageSequence`, `CodexThreadTranscriptSequence`, `CodexThreadLogSequence`, `CodexReviewEventSequence`, `CodexReviewProgressSequence`, `CodexThreadLogEntry`, `CodexDeadlineClock`, `CodexAppServerClock`, `CodexThread.rollback`, `CodexAppServer.Configuration.defaultServerRequestHandler` | high-level thread/review/restart handlesとbuilt-in request policyの内部実装だけ | AppServerKit package implementation | +| `PACKAGE` | `CodexAppServerRequest`, `CodexAppServerRequestResolution`, `CodexAppServerRequestHandler`, `CodexServerRequestID` and all method-specific request/resolution payloads | built-in current-v2 policyとCodexKit package contract testsだけ。新しい実在interactive hostが現れた時に別design gateでpublic化を検討 | AppServerKit + Testing package tests | +| `PACKAGE` | `JSONRPCTransport`, client/router/serializer/replay/registry/lease/supervisor/wire DTO/reducer owners | §4 owner implementation。public transport seamなし | AppServerKit/Testing implementation | +| `DELETE` | raw public `CodexAppServerRequest`/`CodexAppServerResponse` factories and `decodeParams`; obsolete `CodexAppServerError.response`; `CodexNativeWebAuthentication`, `CodexChatGPTLogin`, old `CodexLoginCompletion` | package typed request familyまたはstock login handleへ置換。compat shimなし | replacement above | +| `DELETE` | `CodexReviewResumeOptions`, `CodexTranscriptErrorHandlingPolicy`, API-key/device-code/native-callback login entrypoints、root `cancelLogin/completeLogin`、old terminal aliases/predicates/response `status/finalAnswer/errorMessage`、`cleanupReviewReportingFailures` | current-v2/stock-login/typed-outcome contractとtyped-result `cleanupReview`へ全面移行 | replacement above | + +### 7.2 `CodexDataKit` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `KEEP` | `CodexPersistentModel`; `CodexWorkspaceID`, `CodexWorkspaceGroupID`, `CodexChatItemID`; `CodexWorkspaceGroup`, `CodexWorkspace`; `CodexChatInput`, `CodexChatMessageInput`, `CodexStartedReview` | current semantic model/ID/input surfaceを維持 | ReviewUI, ReviewChatLogUI, MCP, CRK adapter | +| `CHANGE` | `CodexChat`, `CodexTurn`, `CodexItem`, `CodexReviewInput` | obsolete transcript policy、String error/phase、single observation slotを除き、typed error/origin/relation/phaseへ変更 | ReviewUI, ReviewChatLogUI, MCP, CRK adapter | +| `CHANGE` | `CodexModelContainer`, `CodexModelContext`, `CodexModelContextError`, `CodexModelActor`, `CodexDefaultSerialModelExecutor` | §5.6のeager MainActor context、async fetch/action APIs、concrete executorだけ。context escapeを許さない | Host composition, adapter, external fixture | +| `DELETE` | generic `CodexModelExecutor`, `CodexSerialModelExecutor` protocols and those legacy executor protocols' public context getters | concrete executor contractへ置換。`CodexModelActor.modelContext` はactor body内だけで使えるcomputed requirementとして残す | replacement above | +| `NEW` | `CodexSortDescriptor` | supported key-path/comparator/orderを保持しFoundation parityのnil orderingを持つ | ReviewUI queries, fixture | +| `CHANGE` | `CodexSectionDescriptor`, `CodexFetchDescriptor`, `CodexQuery`, `CodexQueryResults`; query environment modifiers | §5.6のpredicate/sort/limit/offset/context-merge fields、stable ID ordering、typed phase。initializer crash/string failureを除く | ReviewUI, external fixture | +| `DELETE` | `CodexFetchRequest` | canonical value `CodexFetchDescriptor`へ統合。Core Dataの名前だけを借りたmutable duplicateを残さない | replacement above | +| `NEW` | `CodexFetchValidationError`, `CodexFetchFailure`, `CodexFetchPhase` | validation/app-server failureの唯一owner | ReviewUI, MCP projection | +| `KEEP` | `CodexFetchSectionID`, `CodexFetchSection`; `CodexFetchedResultsIndexPath`, `CodexFetchedResultsSnapshot`/`Section`, `CodexFetchedResultsSectionChange`, `CodexFetchedResultsItemChange`, `CodexFetchedResultsTransactionReason`, `CodexFetchedResultsTransaction` | current full identity/value transaction interfaceを維持 | ReviewUI and package UI tests | +| `CHANGE` | `CodexFetchedResults` | query/current value/typed phase/snapshot/newest-1 relayの単一owner。String error fieldなし | ReviewUI and package UI tests | +| `DELETE` | `CodexFetchedResultsController` | forwarding-only wrapperを削除し、AppKit consumerは`CodexFetchedResults`を直接保持 | replacement above | +| `NEW` | `CodexTurnTerminalDisposition`, `CodexChatPhase`, `CodexChatSnapshotReason`, `CodexChatObservationSnapshot`, `CodexChatObservationEvent`/`Payload`, `CodexChatItemLocator` | immutable terminal/failure/snapshot/cursor facts and unique item targets | ReviewChatLogUI | +| `CHANGE` | `CodexChatUpdates` concrete sequence/iterator, `CodexChatObservation`, `CodexChatUpdate`, `CodexChat.observe` | §5.6のself-contained sequenced updates、single iterator、explicit close | ReviewChatLogUI | +| `PACKAGE` | `CodexFetchPlanResult`, `CodexThreadQueryPlan`, predicate/sort lowering, mutation strategy, `FetchedResultsLoadCoordinator`, transaction relay, `ChatObservationOwner`, waiter tokens | validation/load/observation owner implementation | CodexDataKit implementation | +| `DELETE` | `CodexDataPhase`, `CodexChatResynchronizationReason`, old existential `CodexChatUpdates` typealias shape、`chatObservationAlreadyActive`, Foundation `SortDescriptor` public lowering/Mirror helpers、`lastErrorDescription`/duplicate failure properties | typed descriptor/phase/observation contractへ置換 | replacement above | + +### 7.3 `CodexAppServerKitTesting` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `NEW` | `CodexAppServerTestError`, opaque `CodexAppServerTestItem`/nested statuses, `CodexAppServerTestTurn`, `CodexAppServerTestThreadMetadata`, `CodexAppServerTestThreadRuntimeMetadata`, `CodexAppServerTestStoredThread`, `CodexAppServerTestThreadPage`, `CodexAppServerTestTurnPage` | typed fixture misuse error、consumer-needed current-v2 canonical fixtures/pages、start/resume/fork response metadata、archive membership。production projectionからwireを逆生成しない | PreviewSupport, DataKit/Host/UI tests, external fixture | +| `NEW` | `CodexAppServerTestModel`, `CodexAppServerTestModelPage`, `CodexAppServerTestAccount`, `CodexAppServerTestBedrockCredentialSource` | lossy production model/account projectionとは別のopaque canonical response fixture。required model metadata、ChatGPT plan、Bedrock credential sourceを保持 | Host/settings tests, external fixture | +| `NEW` | `CodexAppServerTestRateLimitSnapshot`, `CodexAppServerTestRateLimitsResponse` and nested typed metadata | legacy primary/map/credits/spend/reached/reset-creditを保持するopaque current-v2 response fixture。production rate projectionから逆生成しない | Host/settings tests, external fixture | +| `NEW` | `CodexAppServerTestLoginCancellationStatus`, `CodexAppServerTestConfigurationLayerSource`, `CodexAppServerTestConfigurationLayerMetadata`, `CodexAppServerTestConfigurationLayer`, `CodexAppServerTestConfigurationReadResult`, `CodexAppServerTestConfigurationWriteResult` | required origins/layersとnonempty pinned login-cancel/config-write response fixtures。empty successへ縮退しない | Host/settings tests, external fixture | +| `CHANGE` | `CodexAppServerTestThreadStore` | archive-aware authoritative thread store + explicit planned start/fork fixtures。ID/time/runtime metadata fabricationなし | PreviewSupport, DataKit/Host/UI tests, external fixture | +| `NEW` | `CodexAppServerTestOperation`, `CodexAppServerTestRequest` | closed operationとassociated-value semantic request | CRK/Host/UI tests, external fixture | +| `CHANGE` | `CodexAppServerRecordedRequest` | sequence/request ID/semantic request。raw method/data/decode APIなし | CRK/Host/UI tests, external fixture | +| `CHANGE` | `CodexAppServerTestTransport`, `CodexAppServerTestGate`, `CodexAppServerTestRuntime` | §5.7のtyped queues/gates/recording/failure/close and exclusive queued/store runtime modes | PreviewSupport, package consumers, external fixture | +| `NEW` | `CodexAppServerTestDeadlineClock` | deterministic monotonic deadline control | CodexKit/Host tests, external fixture | +| `NEW` | `CodexAppServerTestNotificationEmitter`, `CodexAppServerTestAuthMode`, `CodexAppServerTestPlanType`, `CodexAppServerTestAccountUpdate`, `CodexAppServerTestRateLimitsUpdate`, `CodexAppServerTestLoginCompletion` | opaque Testing fixture、typed agent/plan/reasoning/command/file/MCP/error/status/account/rate/login methodsだけ。post-success readinessは`.chatGPT` auth modeを明示し、raw emitterはpackage | PreviewSupport, Host/UI tests | +| `PACKAGE` | `CodexAppServerTestServerRequestInjector`, injected outcome/no-response family | configured package policy + shared wire codec contract testだけ | CodexKit package tests | +| `PACKAGE` | `enqueueInitialized`、generic/raw enqueue/handler/JSON methods、raw notification emitter、`CodexAppServerRecordedNotification`, waiter token/codec/in-memory wire details | handshake/malformed/framing/shared-codec contract testsだけ | CodexKit package tests | +| `DELETE` | runtime `init(server:transport:)` and mechanical forwarders; old store `init(threads: [CodexThreadSnapshot])`, `snapshot`, `snapshots`, snapshot `upsert/remove`; public raw recorded request fields/initializer/`decodeParams`; `maxActiveCount`/`waitForNotificationStreamCount`/`recordedNotifications` controls; API-key/device-code/native-login fixtures; unstubbed success/fake cancellation success | typed owner APIsへ移行 | replacement above | + +`CodexFileUpdateChange` はTesting型ではなく§7.1のproduction domain valueである。 + +### 7.4 `CodexReviewKit` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `CHANGE` | `@MainActor @Observable CodexReviewStore` | executable lifecycleに必要な `start(forceRestartIfNeeded:)` と `stop()`だけpublic。初期化/compositionはHost/PreviewSupport factoryが所有 | ReviewMonitor app lifecycle | +| `PACKAGE` | store state/actions、throwing signIn/addAccount/cancel auth actions、`CodexReviewAuthenticationFailure`, `CodexReviewServerState`, `CodexReviewAuthModel`, `CodexReviewAccount`, `ParsedReviewResult`, `makePreviewStore` | ReviewUI/MCP/Host/PreviewSupportは同package graphのpackage contractで利用。public productの外部consumer storyには出さない | sibling package targets only | +| `NEW` | package `ReviewRunID`, `ReviewAttemptID`, `ReviewThreadID`, `ReviewTurnID`, `NonEmptyReviewOutput`, validation error、`ReviewThreadIdentity`, `ReviewAttempt`, `ReviewTurnFailure`, `ReviewBackendOperationFailure`, `ReviewBackendFailure`, `ReviewOutputPublicationFailure`, `ReviewRunCore`, `ReviewCompletionCandidate`, `ReviewCompletion`, `ReviewBackendObservedTerminal`, `ReviewBackendTerminal`, `BackendReviewAttempt` | validated identity/output + observed/final terminal分離 + §5.8 typed product lifecycle。publicにはしない | CRK/adapter/MCP package implementation | +| `NEW` | package `ReviewExecutionPhase`, `ReviewLifecyclePresentation`, `ReviewRunPresentation`, `ReviewWorkerState`, `ReviewWorkerSignal`, `ReviewWorkerClock`/network snapshot | durable coreと分離したtyped transient recovery/presentation contract | CRK/ReviewUI/MCP package implementation | +| `PACKAGE` | cancellation arbiter、backend protocols/registries/workers、`ReviewStoreRuntime`/weak commit sink、`ReviewThreadRetentionRegistry` + pending ownership/crash journal/quarantine/retirement completion | §4 owner implementation。published run可視期間はthreadを保持し、final retirementだけがcleanup authority。publication前journal failureだけquarantine rollback | CRK sibling targets/tests | +| `DELETE` | `CodexReviewStoreTestEnvironment`; SPI `requestCancellationDelayForTesting` | launch constantsはTools app internalへ移し、unused timing SPIは削除 | ReviewMonitor app/tests | + +### 7.5 `CodexReviewHost` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `KEEP` | `CodexReviewRuntime`; nested `Preferences` (`defaults`, five stored properties, initializer, `Codable`); `PreferencesStore`; `UserDefaultsPreferencesStore` | runtime preferences load/save composition | ReviewMonitor app/settings | +| `KEEP` | `CodexReviewAppServerLifecycleHandler` | `@MainActor @Sendable (CodexModelContainer?) -> Void` | ReviewMonitor model-source install/clear | +| `CHANGE` | `CodexReviewStore.makeLiveStore(runtimePreferences:appServerLifecycleHandler:)` | native authentication/web-session argumentsを削除した唯一のproduction factory | ReviewMonitor app composition | +| `PACKAGE` | `LoginPurpose`, `LoginRuntime`, `LoginSessionResult/Failure/RootOutcome/TerminalDecision/TerminationReason/Disposition`, `LoginFinalResultCompletion`, `PrimaryAuthenticationReconciliationHandoff`, `IsolatedLoginRuntime`, `LoginSessionDependencies`/`LoginSessionClock`, `LoginOperationState`, `AccountRegistryStore`/mutation lease/journal/reconciliation values, `AccountRuntimeTransitionCoordinator`, `LoginSession`, `HostRuntimeSession` + stop purpose, URL opener/runtime factory | §5.8 private Host login/account/runtime lifecycle。shared-auth mutation leaseはnew runtime expected-account validationまたはdurable debt handoffまで保持し、unconfirmed loginはnon-circular handoff、public auth protocolを作らない | Host implementation/tests | +| `DELETE` | `CodexReviewNativeAuthentication` whole family、native callback/web-session types and factory args、public `CodexReviewHost`/`DirectCodexReviewStoreBackend` paths | stock login handle + private LoginSessionへ置換 | replacement above | + +### 7.6 `CodexReviewMCPServer` + +このtargetのplanned public declarationsは **0**。MCP server/configuration/HTTP/lifecycle/projection implementationはすべてpackage以下で、ReviewMonitor executableだけが同package graphからcompositionする。`MCPReviewSessionRegistry`/start reservation/operation token/close report、`MCPHTTPServerLifetime`/runtime/session context、`ReviewChatProjectionLookup`(`.available/.unavailable/.refreshFailed`)はnew package familyである。Phase 4でpublic/openが1件でも見つかった場合はfailureとする。 + +### 7.7 `ReviewUI` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `CHANGE` | `ReviewMonitorWindowController` | canonical public initは `init(store:codexModelSource:showSettings:)` 1つ。production window compositionに必要な3 valuesをrequiredにする | ReviewMonitor app | +| `CHANGE` | `ReviewMonitorCodexModelSource` | public `init()`、`install(container:)`, `clear()`。`init(modelContext:)`と`modelContext` getterはpackage | ReviewMonitor app; ReviewUI implementation | +| `PACKAGE` | old `ReviewMonitorWindowController.init(store:)`, `init(store:codexModelContext:)`, UI-state/animator/selection/preview-specific initializers; `ReviewMonitorUIState` and presentation policies | app/preview composition ownerへ移す | ReviewUI/PreviewSupport implementation/tests | +| `DELETE` | authentication failure count/warning polling state、nonthrowing `try?` auth actions | throwing store action catch + typed AuthModel phase/presenterへ置換 | replacement in §6 | + +### 7.8 `ReviewUIPreviewSupport` + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `CHANGE` | `ReviewMonitorPreviewContentSource` | public `store`だけ。`codexModelSource`、streaming/runtime/snapshot/count controls、initializerはpackage。constructionはfactoryのみ | ReviewMonitor preview-mode app composition | +| `CHANGE` | `ReviewMonitorPreviewContent.makeContentSource()` | canonical public preview content factory | ReviewMonitor app and preview providers | +| `PACKAGE` | `ReviewMonitorPreviewContent.makeStore()` | external Tool consumerがなく、content source factory内部/PreviewSupport testsだけ | PreviewSupport/tests | +| `PACKAGE` | command-output-only factories、preview account/model factories、append/snapshot/count controls and fixture templates | PreviewSupport/ReviewUI package tests and local preview implementation only | package tests/previews | +| `KEEP` | `ReviewMonitorWindowController.init(previewContent:showSettings:)` | preview content sourceをretainし同じproduction window rootを構成 | ReviewMonitor app preview mode | +| `DELETE` | preview module forwarding `init(appStore:codexModelSource:showSettings:)` | §7.7 canonical production initializerを直接呼ぶ | ReviewMonitor app | +| `KEEP` | `NSViewController.prepareForSwiftUIPreviewRendering()`, `appendPreviewChatLogStreamTickForTesting(after:)` | separate Xcode CI test targetが使うrender/tick seam | `CodexReviewMonitorCITests` | + +### 7.9 `TextTransitions` + +Phase 0 non-goalなので、このmigrationでは次の現行interfaceを`FREEZE`する。 + +| Action | Declaration family | Planned public contract | Direct real consumer | +|---|---|---|---| +| `FREEZE` | `TextTransition` and nested `Content`, `WidthReservation`, `MotionPolicy` | current generated interfaceと完全一致 | ReviewChatLogUI | +| `FREEZE` | `TextTransitionView` including configuration/text/transition APIs and current testing metrics | current generated interfaceと完全一致 | ReviewChatLogUI, TextTransitionsTests | +| `FREEZE` | `TextTransitionAttachment`, `TextTransitionAttachmentViewProvider` | current generated interfaceと完全一致 | ReviewChatLogUI | + +freeze comparisonはDebug/Releaseを分け、`activeTransitionCountForTesting` / `renderedTextWidthForTesting`は現行どおりDebug public、Release absentであることまで固定する。 + +### 7.10 Internal implementation targets + +`CodexReviewAppServer`, `CodexReviewTesting`, `ReviewChatLogUI` のplanned public declarationsも **0** である。3 targetsはsibling targetsからpackage contractだけで利用し、`public/open`を追加しない。具体的にはtyped SDK adapter/restart/interrupt owners、CRK fixture builder、chat projection/presentation typesをすべてpackage以下に置く。`CodexReviewMCPServer`と合わせ、Phase 4のrecursive scanでは4 internal targetsそれぞれが0件であることを検証する。 + +`CodexKit` umbrella targetのpublic enum/product/`@_exported import`はinventory外ではなく明示`DELETE`である。全consumerは`CodexAppServerKit` / `CodexDataKit` / `CodexAppServerKitTesting`をdirect importする。 + +## 8. Variation axes and addition tests + +| Axis | Absorption point | Variant addition trace | +|---|---|---| +| upstream notification method | single exhaustive `AppServerNotificationDecoder` switch + reducer | decoder case 1 + shared DTO 1 + fixture/test 1。plugin registry/type erasureは追加しない | +| server-request method | shared exhaustive codec + connection-owned in-flight registry | typed request/response case 1 + default policy test 1。registryはhandler lifecycleだけを所有 | +| terminal status | exhaustive `TurnOutcomeClassifier` | classifier 1 + outcome contract tests。consumer層は編集しない | +| mutation operation | `CodexThreadQueryPlan.mutationStrategy` | operation mapping 1 + strategy test。fetched-results handlersは編集しない | +| current wire test event | `CodexAppServerTestNotificationEmitter` | typed method 1 + shared DTO test 1。consumer private DTO/runtime forwarderを追加しない | +| live / test transport | package `JSONRPCTransport` | implementation 1。business logic/UIに環境分岐を追加しない | +| login purpose / home policy | Host `LoginPurpose` + `AccountRegistryStore` | borrowed-primary sign-in readiness/reconciliationまたはowned-isolated add transactionを1 case追加。SDK wireやUI stateへpersistence判断を漏らさない | +| login URL presentation | Host `LoginSession` + one-shot `LoginURLOpener` | URL opener implementation 1。challenge mirrorや別notification Taskを追加しない | +| review restart wire affordance | `ReviewRestartCoordinator` | pinned replacementへ1箇所を差し替え、identity/token contractを更新。consumer fallbackを追加しない | +| MCP protocol request lifecycle | fork-pinned MCP `Server` + `MCPHTTPServerLifetime` | request child 1 familyをstructured groupへ追加しstop testを更新。CRK callback Taskを増やさない | +| interrupt stale/no-active affordance | `InterruptRaceResolver` | structured upstream errorが来たらparser/retryをこのownerから削除。consumer層を編集しない | +| review item presentation | typed kind/origin/relation/turn + `ReviewRolloutPresentationPolicy` | renderer mapping 1 + presentation test。raw decoder/general text identityを追加せず、scoped equalityは1 ownerだけ | + +Terminal status は upstream が定義する closed enum であり plugin-style open axisではない。未知 status は `.invalidTerminalStatus` へ保存し、各 consumerへの branch追加を要求しない。 + +## 9. Deletion list + +| Delete / consolidate | Finding | +|---|---| +| `CodexKit` umbrella product/target/test、`@_exported import`、consumerの`import CodexKit` | RA-01 | +| `CodexTurnStatus.isFailure` / invented `.cancelled` / historical status aliases、current `.turnFailed` route、collector/progress/DataKit/CRK status inference、closed/notLoaded outcome synthesis | RA-02 | +| public `CodexResponseStream` / thread-event / review-event / review-progress / message / transcript / log sequences、`streamResponse`、review session `response/events/messages/transcriptUpdates/logEntries/progress/steer/cancel(callback:)` surface | RA-02 / high-level `respond`・`CodexReviewSession.collect/cancel`へ縮約 | +| public `CodexThread.rollback` surface | RA-16 / package `ReviewRestartCoordinator`だけがdeprecated wire methodを呼ぶ | +| raw request/decode/spawn error leakage、top-level serverBusy/retry/duplicate deadline cases、`errorMessage`、`turnFailedWithResponse` terminal error path | RA-03 | +| router-owned Task cycle、cancel-only stop、append-only raw terminal history/lane、ownerless cancellation/server-request tasks | RA-04 | +| native login complete/authentication/WebSession types、factory/config/composition args、default `{}`、untracked resolved request、dead historical notification routes/default fixtures | RA-05 | +| partial command/file item replacement、valid-v2 missing-ID UUID fallback、consumer-side rate-limit merge | RA-06 | +| per-handler mutation guards、direct concurrent `load()` commit paths | RA-07 | +| predicate/sort/section `preconditionFailure`、Foundation `SortDescriptor` Mirror signature、generic `CodexDataPhase`/duplicate failure fields、implicit archive injection | RA-08 | +| hand-written current DTOs、runtime notification forwarders、direct fake handler invocation、preview archived-ID store、unstubbed success、fake cancellation success | RA-09 | +| account stream runtime-death side-channel、single observation slot、consumer previous-task serialization | RA-10 | +| Hostの9 login fields、`PendingLoginRuntimeCleanup`、8 manual reset clusters | RA-11 | +| optional successful Completion、String mailbox failure、`lifecycleMessage/errorMessage`、3-stage output fallback、`ReviewRunCore.finalReview`、store/MCP assistant fallback | RA-12 | +| optional String identity groups、`"attempt-1"` 3箇所、SDK identity再構成、resume-to-cancel fallback/tests | RA-13 | +| rawPayload decoder、reasoning/general text-pair state machine、item-status rederivation、snapshot-only projection、duplicated review-marker IDs、implicit full reprojection path。review-rollout scoped equalityだけ§13まで残す | RA-14 | +| `CodexReviewHost` class、`DirectCodexReviewStoreBackend`、専用tests、mechanical ForTesting forwarder chains | RA-15 | +| MCP stream callback ownerless Tasks、transport-only disconnect、unjoined upstream Server request children | RA-17 / forked structured Server + lifetime stopへ置換 | + +## 10. Avoided shapes + +- `CodexAppServerNotificationRouter` に close task、history TTL、server request registry、rate-limit cacheを足して新 god actorにしない。それぞれを §4 ownerへ置く。 +- child handleをweak connection referenceにしてroot ARC lifetimeへ依存させない。live handleはshared connection lease、terminal handleはcompact snapshot leaseを持つ。 +- `CodexResponseStream.collect()` の cancelled/interrupted bugを `catch { if Task.isCancelled ... }` の call-site guardだけで隠さない。terminal classifierとconnection terminationを修正する。 +- `CodexTurnStatus.isFailure2` のような別 predicateを追加しない。status解釈は `TurnOutcomeClassifier` だけが行う。 +- current/historical methodを同じ router switchへ戻さない。raw compatibilityはTestingの明示 escapeだけに置く。 +- notification/server-request methodをdictionary registrationするplugin registryを追加しない。lockstep current-v2 codecは単一exhaustive switch、request registryはin-flight Taskだけを所有する。 +- `CodexFetchedResults` の insert/archive/remove handlersへ新しい `requiresRefresh` guardを追加しない。query plan strategyを変更する。 +- `CodexQuery.update()` で validation failureを `try?` や空配列へ落とさない。`CodexQueryResults.phase = .failed(...)` へ投影する。 +- `LiveCodexReviewStoreBackend` から login fieldsを helper extensionsへ移すだけの偽分解をしない。`LoginSession` が stateとtasksをstored propertyとして所有する。 +- async closeを `defer { Task { await close() } }` やdeinit Taskへ逃がさない。外部authorityが明示completionをawaitする。 +- `ReviewRunCore.finalReview` の代わりに別 snapshot/cacheを追加しない。content ownerはCodexChat projectionだけにする。 +- `ReviewMonitorCodexChatLogProjection` にgeneral text hash、raw payload kind、marker constant、missing-target時のfull render fallbackを追加しない。text比較が必要なreview-rollout pairは `ReviewRolloutPresentationPolicy` 1箇所から漏らさない。 +- typed backend/SDK failureを `localizedDescription` だけへ変換してmailbox/lifecycleへ保存しない。message生成はpresentation boundaryだけで行う。 +- `CodexReviewStore` の cancellation-winsとreview worker generation guardsをSDK outcome cleanupの一部として削除しない。 +- MCP shutdown gapをCRK側のdelay、task-count polling、transport.disconnectだけで隠さない。request childを生成するforked `Server` ownerでjoinし、`.build/checkouts`へのuntracked patchを成果物にしない。 + +## 11. Test plan + +| Contract | Characterization before move | Target contract tests | +|---|---|---| +| terminal outcome | current failed response、server interrupted、caller cancellation、alias/missing/inProgress/malformed terminal | strict classifier/collector/progress + `CodexTurnError` decode + DataKit phase + CRK adapter | +| close/resource | router start/stop、process close、late result replay | root drop with live child、close from every public handle、double/drop close、EOF signal、handler-initiated reentrant close without self-await、router/control/handler quiesce before domain terminal、process reap、per-turn state lease release、late result + one terminal event、factory failure full cleanup | +| request error/deadline | JSON-RPC server error、encode/write/decode error、queued cancellation | typed data/requestID/method/purpose preservation、handshake precedence、manual-clock request/turn timeout、overload exhaustion、pre-write cancel no wire、post-write cancel holds lane through response、next same-scope request cannot overtake、startThread/fork/review/login late identity cleanup before `CancellationError`、waiter cancellation without shared-operation cancel | +| transport seam | process transport notification/request/close | live/test raw-frame parity、ready16 + globally accepted 17thだけlossless drain、concurrent 18th+ producersはpayload未受理/close reject、transport-owned payload max17、waiter cancel、post-close reject、16 MiB reject、notification→response causal commit、early event、malformed→responses-only drain、finish precondition、process-exit arbitration、close/wait/reap order | +| MCP Swift SDK dependency | 0.12.1 untracked request Task / early stop return | fork receive-loop structured child ownership、concurrent requests、handler failure/cancel、stop admission close、transport disconnect、request children + receive-loop + pending continuation join、handler exit signal no self-await、double stop、stop return時task count 0 | +| server requests | process-backed approval | integer/string request ID round-trip、9 current-v2 cases/default policies、shared-codec injection、mismatch/throw internal error、required-thread resolved→noResponse、close→noResponse、no hung continuation、unknown reject | +| item/rate/account merge | command/file partial fixture、account read | started→multiple delta→completed metadata、package canonical DTOでpinned全item/turn/thread variant exhaustive round-trip、opaque public fixture projection一致、patch snapshot、missing-ID malformed、sparse rate merge、accountChanged coalescing + explicit refetch、全notification disposition fixture。pinned inventoryにない`item/updated` emitterは作らない | +| query/load/results | existing mutation and pagination tests | serialized intent/cancellation matrix、atomic target-window refresh across pages、2nd performFetch、empty→new item page-1 refresh、nil cursor no-op、single strategy table、no stale commit、slow transaction consumer newest-1 drop→old mismatch→new snapshot replace、subscriber cancellation/deinit finish、forwarding controller 0 | +| query validation/scope | supported predicate/sort cases | stable success/failure signature、single typed phase、negative limit/offset、unresolved section、optional Date/String comparator nil forward/reverse、missing SwiftUI context fail-fast、nil active-only、`.both` active+archived/context merge/dedupe、全effective sortのsame-direction typed-ID tie-break、created/updated同timestampを跨ぐrecency-cursor exhaustive enumeration、global order後section first-occurrence projection、Mirror/FetchRequest removal | +| fake | current thread store/list | exclusive queued/store modes、archived seed/sort/pagination、explicit planned start/fork fixture consumption + request mismatch reject、resume/read/mutation、fork source immutability、thread runtime metadata full response、opaque model page required description/default effort/modalities/service tiers、account ChatGPT required plan/Bedrock credential source、rate primary/map/credits/reset metadata、config read required origins/layers、rollback/login-cancel/config-write nonempty responses、semantic recorded request associated values、typed queue/gate/emitter wire shape、raw API package-only、unstubbed→typed contractViolation、cancel/late response parity、waiter/runtime close drain | +| observation | multicast relay and active-slot rejection | atomic first snapshot→delta、join current revision、strict `(generation,sequence)` cursor、generation restart seq0 + old pending discard、refresh/includeTurns snapshot barrier、257th event compaction、second overflow supersede、fast/slow isolation、single iterator fail-fast、self-contained update/no live graph read、invalid target/index、failure before first snapshot、failure with buffer、close-vs-failure、finished-generation event rejection、non-last/last close completion、ReleaseSignal close/deinit race dedupe、deinit actor Task 0、owner close receiver join | +| login handle | existing broad completion stream/native login | pending conflict + start cleanup、matching/missing/stale ID、`login/completed(success)`時点ではresult未完→post-success `account/updated(authMode:.chatGPT)`だけでsuccess、先行update無視、nil/non-ChatGPT auth mode/malformed update→committed-needs-reconciliation、no-auth→B/A→B stale read characterization、success-update間connection death→committed-needs-reconciliation、start-owned readiness deadline + multi-result waiter isolation/replay、first cancel claimant-owned deadline + concurrent join、cancel canceled/notFound first-terminal-wins、success-pending中late cancel、root drop/close lease release | +| LoginSession / account registry | existing success/factory failure/runtime death + corrupt-registry empty fallback | borrowed-primary signIn identity維持とrequired B、owned-isolated addのaccount read→close/reap→auth copy→registry replace順、completion/cancel両順序、SDK failure/cancel、URL-open late success、unconfirmed primary handoffはLoginSession cleanup→old stop→new staged runtime→actual account/registry repair→lease release/result resolveで循環なし、concurrent result/cancel/Host stop same winner/cleanup/lease once、file validation/copy/exclusive-create/fsync/registry-write failure、journal全crash cut(replace後phase未更新、active logout成功直後phase未更新を含む)、irreversible effect前prepared journal fsync、sign-out irreversible forward recovery、legacy migration/missing revision/corrupt fail-fast/orphan GC、login vs switch/remove/sign-out/reorder/metadata lease rejection、switch/active-remove/sign-out mutation leaseがjournal prepare→old stop→disk commit→new staged runtime→expected account/nil validation→gate reopenまで継続、account transitionとfinal stopのprecommit abort/postcommit forward-complete/二重stop 0、commit前/後failure、old-generation account event reject、typed UI catch | +| Host runtime/model source | current account/runtime stream tasks | app-server/container/subscription/bootstrap/visible-run rebind/MCP bind各staging failureでcallback 0/resource 0、succeeded projection rebind failureはgate closed、accepted `[nonnull,nil]` exactly once、MCP gate last-open、request-at-activation fully published、forced restart ordering、old late exit cannot clear new source、consumer self-exit no self-await、recorded preserving stop order MCP→review→login disposition(handoff final result非await)→restart identities retention merge→adapter0→cleanup 0→app-server、login handoff coordinator replacement/validation後result resolve、final stopはtransition coordinator quiescence→resolver/UI全run retire→retention cleanup/journal update→app-server、pre/post account commit concurrent final stopでdouble stop 0、startup orphan cleanup success/remove・failure/retain・visible restore 0、stop後callback/event 0 | +| Preview runtime | current preview stream Tasks/TestRuntime no close | `makeContentSource` store owns lifetime、stream/notification cancel+await、observation close、TestRuntime close、container release、double stop、window/app termination calls store.stop、source drop backstop but explicit stop required | +| review restart / recovery / interrupt race | current deprecated rollback、stale/no-active string retry、multi-Bool recovery | prepare interrupt+typed ack、intentional interrupted非product、confirmed outage前result group full drain、result child cancellation時observed-terminal cached recheck→knownはsingle finalizer/unknownはwaiter-cancel、drain中backend terminal candidateがdebounce/network-finish/waiter-cancelより優先、publication barrier exactly 1、raw `.unsatisfied/.requiresConnection`→same outage normalization、static sourceはowner cancelまでfinish 0/manual explicit finishだけtyped failure、signal×stage exhaustive table、satisfied→pending、repeated outage deadline非reset、pending terminal/connection/satisfied matrix、network source unexpected finish、settle中再切断 + old settle timer reject、unexpected prepare/restart cancellation typed failure、old/future generation/epoch disposition、prepare success時old registry 0/closure release、preparing/waiting/restarting cancel+stop、same-signature join/different reject、rollback success retryでもrequest 1、rollback unknown/post-write start unknownはretry 0、cleanup-vs-restart両順序/late session 0/token map pruning、CRK auto retry 0、exact interrupt parser budget | +| CRK output/identity/presentation | missing output failure、generation/cancel regressions | empty/whitespace ID/output init+decode reject、typed inline/detached identity round-trip、initial/restart identity pending claim + journal commit before running/generation publish、journal failure→interrupt/session close→same-runtime cleanupReview→typed failure、journal+cleanup二重failure→gates-closed quarantine/runtime retain→journalまたはcleanup retry成功までfinal stop join、identity-known→publication/terminal間の全crash cutでstartup orphan cleanup可能、publication barrier terminal-before-DataKit/refresh commit、missing/empty/mismatch/refresh failure→failed、barrier中cancel wins、no fallback/storage、legal/illegal core×phase matrix、outage/cancelling UI+MCP mapping、late attempt cleanup、all terminal/abandon live registry 0、terminal後CodexChat readable、preserving restart/account switchはthread cleanup 0 + same identityからprojection rehydrate、final stopはresolver removal before cleanup、cleanup success journal removal/failure tombstone維持、crash-startup orphan cleanup + visible run restore 0、cycle-free stop/drop、duplicate cancel shared completion | +| MCP session/HTTP/projection | current field shape、caller session selector、transport-only close | reserve/close/bind全順序、cross-session read/list/await/cancel、terminal membership、concurrent close join、sessionClosed queued/running/late cleanup、in-flight operation drain/tombstone pruning、initialize/DELETE/timeout/server-stopがsame closeSession順(logical close nonawait→Server/transport close→request child join→registry driver await→stream/context removal)、first-await前initializing context登録、close後late Server cleanup-only/publication 0、active review/open stream timeout blocking、heartbeat非activity、listener/protocol/transport/NIO/task count 0、succeeded unavailable/empty→invariant error、non-success unavailable JSON維持、same `CodexTranscript.reviewOutputText`、assistant fallback 0 | +| presentation | equal/different-text review rollout marker+assistant pair(outer completedを含む)、user→first marker→last marker removal、marker中のuser insert/update、distinct reasoning items | fixed-ID companion contract、single scoped equality policy、per-turn user visibility reconcile、typed origin preservation、legacy reasoning exclusion、no raw decoder/general pairing/marker copy、all snapshot reasons full replace、self-contained update-kind/move matrix、missing target invariant | + +Default tests remain deterministic and do not start a live app-server。async tests use gates/continuations/task completion, not sleep。CodexKit に別 package fixtureを追加し、3 public productsのimport/linkとin-memory runを検証する。live sampleはopt-inでありacceptance testに含めない。 + +## 12. Audit finding mapping + +| Audit finding | Design owner / disposition | +|---|---| +| `arch-login-state-no-owner` | RA-11 / `LoginSession` | +| `ask-cancel-collect-misreported` | RA-02 / `CodexTurnOutcome` + cancellation preservation | +| `ask-process-leak-no-deinit` | RA-04 / connection close + process token | +| `ask-router-history-unbounded` | RA-04 / handle-owned replay lease + lane cleanup | +| `conf-interrupted-is-failure` | RA-02 / exhaustive classifier | +| `cov-invented-login-complete` | RA-05 / stock login only | +| `cov-rollback-deprecated` | RA-16 / §13 external contract wait、single coordinatorへ隔離 | +| `dx-error-taxonomy-uncatchable` | RA-03 / layered public errors | +| `dx-no-timeout-story` | RA-03 / handshake/request/turn deadlines | +| `test-store-ignores-archived-and-sort` | RA-09 / authoritative test thread store | +| `use-item-identity-text-dedup` | RA-14 / typed presentation facts | +| `use-terminal-cascade` | RA-02 / SDK outcome only; product cancellation arbiter remains | +| `arch-attempt-id-fallback` | RA-13 / required `ReviewAttempt` | +| `arch-closed-maps-to-failed` | RA-02 / generation end never synthesizes outcome | +| `arch-host-target-test-only` | RA-15 / class/direct backend delete、target keep | +| `arch-testing-forwarder-chains` | RA-15 / owner-level test APIs only | +| `ask-interrupt-error-string-parsing` | RA-16 / §13 external contract wait、fixed retryを隔離 | +| `ask-thread-closed-terminal` | RA-02 / typed generation/connection end、no outcome synthesis | +| `conf-command-delta-clobbers-item` | RA-06 / `CodexItemReducer` | +| `conf-error-payload-discarded` | RA-03 / request failure data | +| `conf-ratelimits-replace-not-merge` | RA-06 / `AccountEventHub` + type-owned merge | +| `conf-server-request-resolved-ignored` | RA-05 / `ServerRequestRegistry` | +| `cov-dead-compat-notifications` | RA-05 / current-v2-only policy | +| `cov-error-warning-untyped` | RA-10 / typed connection events | +| `cov-server-requests-untyped` | RA-05 / typed requests/resolutions | +| `dk-closed-fabricates-completion` | RA-02 / synthetic completion delete | +| `dk-implicit-archived-scope` | RA-08 / explicit scope semantics | +| `dk-mutation-strategy-scattering` | RA-07 / query-plan strategy | +| `dk-optional-delta-id-workaround-layer` | RA-06 / current-v2 ID required、malformed otherwise | +| `dk-predicate-runtime-crash-contract` | RA-08 / typed validation failure | +| `dk-query-not-live-external` | RA-08 / explicit refresh freshness contract | +| `dk-single-observation-slot` | RA-10 / shared pump + leases | +| `dk-sortdescriptor-mirror-reflection` | RA-08 / throwing supported sort lowering | +| `dk-unserialized-fetch-loads` | RA-07 / load coordinator | +| `dx-cancel-semantics-inconsistent` | RA-02 / explicit cancel vs caller cancellation contract | +| `test-fictional-turn-failed-pins-phase-contract` | RA-02/RA-09 / current terminal builder | +| `test-handrolled-notification-schemas-drift` | RA-09 / shared DTO + typed builders | +| `test-no-server-request-injection` | RA-05/RA-09 / fake request injection | +| `test-process-crash-recovery-untested` | RA-04/RA-10 / connection termination tests | +| `test-unstubbed-method-silent-success` | RA-09 / strict fake | +| `test-fake-cancel-in-flight-returns-success` | RA-09 / cancellation parity | +| `use-dual-thread-identity` | RA-13 / `ReviewThreadIdentity` | +| `use-full-reprojection` | RA-14 / granular typed update path | +| `use-highlevel-surface-bypass` | RA-02 / adapter consumes terminal outcome | +| `use-mcp-refresh-fallback` | RA-12 / typed projection lookup | +| `use-observe-serialization` | RA-10 / awaitable shared observation | +| `use-resume-to-cancel` | RA-13 / unreachable fallback delete | +| `use-review-output-location` | RA-12 / one adapter extraction + CodexChat owner | +| `use-runtime-death-side-channel` | RA-10 / connection termination event | +| `use-review-marker-duplication` | RA-14 / snapshot path/marker copy delete | +| MCP protocol request children not joined on stop | RA-17 / fork-pinned `Server` structured request ownership + `MCPHTTPServerLifetime.stop()` | + +Appendix B は Phase 0 non-goal のため、この対応表へ昇格させない。実装中に同じ owner の根本原因として静的に確定した項目だけ、design doc更新後にscopeへ追加する。 + +## 13. Intentional external-contract waits + +### Deprecated `thread/rollback` + +Review restartは現行 product featureだが、pinned upstreamにreplacementがない。`ReviewRestartCoordinator` だけが deprecated requestを呼ぶ形へ隔離し、README/DocCにupstream dependencyを明記する。replacementを推測した adapter、fallback、capability flagは追加しない。upstreamがreplacementまたはremoval releaseを公開した時点を再設計 triggerとする。 + +### Interrupt race string error + +pinned upstreamは stale/no-active interruptをstructured dataで返さない。terminal-known local fast pathで不要なrequestを0にし、message parser/fixed retryは `InterruptRaceResolver` 1 ownerへ隔離する。pinned exact message `no active turn to interrupt`だけはinitial attempt後に最大5回、50 ms間隔でretryし、sleepはinjected monotonic `CodexDeadlineClock`を使う。`expected active turn id but found `だけはactual IDをlosslessにparseし、cleanup identityをcoordinatorへ渡してactual IDへ1回interruptする。二回目のmismatch、budget exhaustion、その他のmessageは元のtyped request failureを返し、`CancellationError`をfabricateしない。このparserを「typed解決済み」とは扱わず、structured upstream errorまたはtyped interrupt-current affordanceが追加された時点で削除する。それまでは consumer層へstring判定を漏らさない。 + +### Review rollout companion equivalence + +pinned upstreamは `review_rollout_assistant` が同一review-exit operationのcompanionであることを実装上示すが、markerとassistantの表示内容が等価かどうかをv2 wireへ載せない。さらにreview child failure/receiver close等ではouter turnが`.completed`でも両textが異なる。exact current UIを守るため、typed companion pairへ限定したdisplay-text equalityを `ReviewRolloutPresentationPolicy` に残す。general item identityやmergeには使わない。upstreamがshared operation ID、content-equivalence flag、またはcanonical single output itemを提供した時点でこの比較を削除し、pin bump contract testを更新する。 + +## 14. Migration waves + +1. **W0 — characterization and topology**: current-v2 terminal/error/close/query/fake/CRK invariantsをtestsで固定し、CodexKit umbrella削除とdirect importsへ移行。 +2. **W0.5 — MCP dependency lifecycle**: Swift SDK forkでrequest childrenのstructured ownershipとawaitable `Server.stop()`を実装・package testし、明示publish承認後にexact fork commitへpinして`Package.resolved`を更新する。CRK変更より先にfork contractをgreenにする。 +3. **W1 — AppServer terminal/error**: `CodexTurnOutcome`、legal turn snapshot、events/progress/collect、layered errors、deadlines。DataKit/CRK compile migrationを同じ変更系列で完了し、旧 classifierを削除。 +4. **W2 — AppServer lifecycle/wire**: `JSONRPCTransport`、connection close authority、strong handle leases、process token、handle replay、`ThreadEventHub` + request前generation checkpoint、lane cleanup、typed server requests、notification disposition、diagnostics、item/account reducers、strict current-v2 decoder。各変更でTesting transportも同じseamへcompile migrationする。 +5. **W3 — stock-login/account vertical slice**: SDKを `account/login/start` / cancel / completed + post-success account-update readinessへ縮約し、**同じintegration wave**でTesting login emitter/transport controls、Host `LoginSession`/`AccountRegistryStore`/`AccountRuntimeTransitionCoordinator`(unconfirmed-login handoff、journal-before-logout、final-stop arbitrationを含む)、CRK store/AuthModel throwing actions、ReviewUI catch sites、Tools factory/Host tests、one-shot URL openerへ移行してnative auth/WebSession/failure-count surfaceを削除する。SDKだけを先に削除してHost/UI/testsをcompile不能にする中間commitや、login fixtureをW4へ先送りするcommitは作らない。 +6. **W4 — non-login Testing/DataKit**: opaque current-v2 fixture、strict thread store/transport/emitter/injector、typed query plan、mutation strategy、load coordinator、shared observation、deterministic external fixture。 +7. **W5 — CRK core/recovery/MCP**: validated IDs/output、typed backend terminal/failure、publication barrier、cycle-free runtime、phase-scoped recovery worker、restart coordinator、in-memory run lifetimeへ揃えた`ReviewThreadRetentionRegistry`/crash orphan journal/final retirement、MCP session reservation/isolation/HTTP lifetimeをowner順に移行し、synthetic/fallback/resume-to-cancelを削除する。 +8. **W6 — presentation/dead surface cleanup**: typed run presentation、item origin/relation、granular projection、connection termination consumption、dead host/testing-forwarder cleanup、docs同期。 +9. **W7 — acceptance**: dependency pin/package graph/public inventory、external fixtures、fork/CodexKit/CRK package tests、ReviewMonitor xcodebuild、codex-review、before/after metrics。 + +各 implementation worker は専用 worktree/task branchで作業し、owner単位で commitする。API/owner変更が必要なら実装を止めてこの文書へescalateする。forked MCP SDK→CodexKit→CRKのdependency orderでlocal commitsを統合してから次waveを検証する。MCP fork publish、CodexKit remote fallback revision更新、pushはそれぞれ外部状態変更の明示承認後にだけ行う。 + +## 15. Acceptance criteria + +- `CodexKit` products 4→3、`@_exported import` 2→0。全 consumerが必要 productをdirect importする。 +- MCP Swift SDKはexact fork commitへpinされ、fork diffはrequest-child structured ownership/awaitable stopに限定される。`Server.stop()` return時のreceive/request/pending task count 0をdependency package testとCRK integration testの両方で証明し、`.build/checkouts` local patchは0件である。 +- public/open inventoryが§7と一致し、CodexKit external fixtureが`@testable`なしで3 productsをbuild/linkし、in-memoryでrunする。review cleanupはtyped-result `cleanupReview` 1経路で、`cleanupReviewReportingFailures`とfailureを捨てるvoid overloadは0件。internal 4 targetsはpublic 0、TextTransitionsはDebug/Release別のfrozen interfaceと一致する。`CodexReviewMCPServer`の未使用library productを追加しない。default acceptanceはlive binary/auth/networkへ依存しない。 +- `isFailure`/historical aliasによるterminal分類、synthetic outcome、current `turnFailed` fixture、String-only request/turn/backend failure、`"attempt-1"` 3件、login reset cluster 8件、output fallback chain、resume-to-cancel、account-stream death side-channelが0件。 +- `JSONRPCTransport`、router/replay/serializer、全9 server requests、load coordinator、chat observation/release signal、LoginSession/AccountRegistryStore/AccountRuntimeTransitionCoordinator、phase-scoped review worker、`ReviewRestartCoordinator`、MCP session/HTTP lifetime、`InterruptRaceResolver`の各 ownerにstart/cancel/close/completionまたはdecision contract testがある。root drop中のlive child、terminal replay、deprecated rollback単一路、terminal-known no-request、raw unsatisfied/requiresConnection normalization、outage epoch/retry disposition、DELETE/timeout/global stop共通per-session close順も固定される。 +- `LiveCodexReviewStoreBackend` からlogin 9 state valuesとnative WebSession/auth composition surfaceが消える。`.signIn`はborrowed primary runtimeでpost-success `.chatGPT` readinessを待ちno-auth→B/A→Bをrequired Bへ収束、unconfirmed commitはlease/final resolverをcoordinatorへhandoffしてold stop→new runtime validation→forward repair後にだけresultをresolveする。`.addAccountPreservingActive`はfile-backed isolated runtimeをclose/reap後にunique immutable byte revisionをimportし、current active keyをstore isolation内で保持したregistry atomic replaceだけでcommitする。load/switch/remove/sign-out/reorder/refreshもAccountRegistryStore/journal ownerを通り、active logout/revokeより前にprepared journalをfsyncし、corrupt/missing revisionをemptyへfallbackしない。`ReviewRunCore` からidentity optional群、finalReview、`lifecycleMessage/errorMessage` storageが消え、validated IDs/output、typed failure/phaseだけが残る。 +- Host accepted runtimeごとにmodel-source nonnil 1回、nil-before-close 1回で、MCP listenerはbind済みstaged→fully published後gate-openとなる。preserving stopはMCPHTTP lifetime→review/login disposition/restart drain→retention merge→adapter0→thread cleanup 0→app-server、login reconciliation handoffはその後new staged runtimeのactual account validation/registry repair/lease release/result resolveへ進む。final store stopはAccountRuntimeTransitionCoordinatorでin-flight mutationをpre-effect abortまたはpost-effect forward-completeしてvalidated cleanup sessionを得た後、全run resolver/UI retire→retained thread cleanup/journal update→app-serverの順をawaitし、double stop 0である。cleanup failure/crash journalは次startupにvisible runをrestoreせずorphan cleanupし、old generation callback/eventはnew sourceへ影響しない。Host connection/account consumersとPreview stream/notification/TestRuntimeはstore stop completionまでにcancel + await/closeされ、stop後のcallback/eventは0件である。 +- `CodexDataPhase`、duplicate query failure、Foundation `SortDescriptor` Mirror loweringが0件。`.both` scopeとloaded-window refresh/cancellation testsがpassする。 +- current notification fixture用のconsumer private DTO clusterとruntime forwarding methodが0。production snapshotをwire-isomorphicと扱わず、opaque Testing DTOだけをencodeする。pinned全notification methodがroute/diagnostic/explicit-ignore dispositionを持ち、raw emitterはmalformed/future testsだけ、server-request injectionはshared wire codecだけを通る。 +- attempt identityはinitial/restartとも`ReviewThreadRetentionRegistry` crash journal commit後だけrunning/generationへpublishされる。`.succeeded`は`NonEmptyReviewOutput` + one authoritative refresh + same `CodexTranscript.reviewOutputText` exact projection barrier通過後だけcommitされる。MCP succeeded lookupのunavailable/empty/mismatchはtool invariant errorで`finalReview:null`を返さず、assistant-last/output fallbackは0件である。 +- ReviewChatLogUIのrawPayload decoder、reasoning/general normalized-text pairing、duplicated marker IDs、updateごとのfull reprojectionが0。text equalityはtyped review companionへ限定した `ReviewRolloutPresentationPolicy` 1 ownerだけで、origin/relationと全update-kind targeted testsがpassする。 +- platform gateはCodexKit 1 / CRK 0を超えず、残る1件をlocal-process composition boundaryとして説明できる。 +- largest file / access / condition scatterを再計測し、単なるfile moveではなくowner state移動とconsumer call-site単純化を示す。 +- forked MCP SDK package tests、CodexKit package tests、CodexReviewKit package tests、ReviewMonitor xcodebuild tests、`git diff --check` が通る。baseline UI flakeは全体再実行とtargeted再実行の両方を記録する。 +- `codex-review` がclean。旧経路、compat shim、未計画public surfaceが残らない。 diff --git a/Package.resolved b/Package.resolved index 2adb99a3..70425b5d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6e69018f1a7fb5b827d1f08721f3c54d5891b56910c97b1b90b3f9742625011b", + "originHash" : "6c55e0749603b3a3418923fe53a26d7fca40e9ae5808ac604c91bb89a9212236", "pins" : [ { "identity" : "eventsource", @@ -67,10 +67,9 @@ { "identity" : "swift-sdk", "kind" : "remoteSourceControl", - "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "location" : "https://github.com/lynnswap/swift-sdk.git", "state" : { - "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", - "version" : "0.12.1" + "revision" : "fae7761fd5d257b24e1d9c49c6dc121e188e0d9b" } }, { diff --git a/Package.swift b/Package.swift index eecb848a..5b437171 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let packageDirectory = URL(fileURLWithPath: #filePath) let localCodexKitPath = packageDirectory .appendingPathComponent("dependencies/CodexKit", isDirectory: true) .path -let codexKitFallbackRevision = "3f6216c01c91bf14737e6fe40c30efef7a5bbd04" +let codexKitFallbackRevision = "2ec1fd20907ed25dca0dcf0f7ea754e57953ad29" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) @@ -42,7 +42,10 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", exact: "0.12.1"), + .package( + url: "https://github.com/lynnswap/swift-sdk.git", + revision: "fae7761fd5d257b24e1d9c49c6dc121e188e0d9b" + ), .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")), codexKitDependency, @@ -60,8 +63,8 @@ let package = Package( .target( name: "CodexReviewAppServer", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewKit", ], swiftSettings: [ @@ -71,7 +74,8 @@ let package = Package( .target( name: "CodexReviewMCPServer", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewKit", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), @@ -85,8 +89,8 @@ let package = Package( .target( name: "CodexReviewHost", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewKit", "CodexReviewAppServer", "CodexReviewMCPServer", @@ -111,7 +115,8 @@ let package = Package( dependencies: [ "CodexReviewKit", "ReviewChatLogUI", - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), .product(name: "ObservationBridge", package: "ObservationBridge"), ], swiftSettings: [ @@ -122,7 +127,8 @@ let package = Package( name: "ReviewChatLogUI", dependencies: [ "TextTransitions", - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), ], swiftSettings: [ .swiftLanguageMode(.v6), @@ -131,7 +137,8 @@ let package = Package( .target( name: "ReviewUIPreviewSupport", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", "ReviewUI", @@ -158,6 +165,7 @@ let package = Package( dependencies: [ .product(name: "CodexAppServerKit", package: "CodexKit"), .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting", @@ -169,7 +177,8 @@ let package = Package( .testTarget( name: "CodexReviewMCPServerTests", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewMCPServer", "CodexReviewTesting", .product(name: "NIOCore", package: "swift-nio"), @@ -194,7 +203,8 @@ let package = Package( .testTarget( name: "ReviewUITests", dependencies: [ - .product(name: "CodexKit", package: "CodexKit"), + .product(name: "CodexAppServerKit", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), .product(name: "CodexAppServerKitTesting", package: "CodexKit"), "CodexReviewKit", "CodexReviewTesting", diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index c6ad9a61..b748b130 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -1,6 +1,6 @@ import Foundation -import CodexKit import CodexAppServerKit +import CodexDataKit import CodexReviewKit import OSLog @@ -18,28 +18,19 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private let appServer: CodexAppServer nonisolated package let modelContainer: CodexModelContainer - nonisolated package let modelExecutor: any CodexModelExecutor - private var reviewEventSessionsByAttemptID: [String: AppServerReviewEventSession] = [:] - private var activeReviewAttemptIDByThreadID: [String: String] = [:] - private var activeThreadIDsByAttemptID: [String: Set] = [:] - private var reviewEventSessionCanonicalThreadIDByThreadID: [String: String] = [:] - private var abandonedReviewAttemptIDs: Set = [] - private var inFlightRestartCountByInterruptedAttemptID: [String: Int] = [:] - private var completedReviewEventSessionMetricsByThreadID: [String: ReviewBackendEventSessionMetrics] = [:] - - private struct DeferredReviewThreadCleanup { - var run: CodexReviewBackendModel.Review.Run - var additionalCleanupThreadIDs: [String] - } - - // Terminal reviews keep their chats readable (sidebar, MCP log reads) - // until runtime teardown: cleanupReview defers the destructive thread - // deletion here and cleanupActiveReviewsForShutdown flushes it. - private var deferredThreadCleanupsByAttemptID: [String: DeferredReviewThreadCleanup] = [:] - - package init(appServer: CodexAppServer, modelContainer: CodexModelContainer? = nil) { - let modelContainer = modelContainer ?? CodexModelContainer(appServer: appServer) - self.appServer = appServer + nonisolated package let modelExecutor: CodexDefaultSerialModelExecutor + private struct ActiveReviewSession { + let attempt: ReviewAttempt + let session: CodexReviewSession + let chat: CodexChat + } + + private var activeReviewSessionsByAttemptID: [ReviewAttemptID: ActiveReviewSession] = [:] + private var abandonedReviewAttemptIDs: Set = [] + private var inFlightRestartCountByInterruptedAttemptID: [ReviewAttemptID: Int] = [:] + + package init(modelContainer: CodexModelContainer) { + self.appServer = modelContainer.appServer self.modelContainer = modelContainer self.modelExecutor = CodexDefaultSerialModelExecutor(modelContainer: modelContainer) } @@ -86,59 +77,39 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { try await appServer.rateLimits() } - package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws - -> CodexReviewBackendModel.Login.Challenge - { - if let callbackScheme = request.nativeWebAuthenticationCallbackScheme { - let login = try await appServer.loginChatGPT( - nativeWebAuthentication: .init(callbackURLScheme: callbackScheme) - ) - return login.backendChallenge() - } - let handle = try await appServer.loginChatGPT() - return try handle.backendChallenge( - nativeWebAuthenticationCallbackScheme: nil - ) - } - - package func completeLogin( - _ challenge: CodexReviewBackendModel.Login.Challenge, - callbackURL: URL - ) async throws { - try await appServer.completeLogin( - id: .init(rawValue: challenge.id), - callbackURL: callbackURL - ) - } - - package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { - try await appServer.cancelLogin(id: .init(rawValue: challenge.id)) - } - package func logout(_: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot { try await appServer.logout() return try await readAuth() } package func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - let review = try await startReviewSession(for: request) - let attemptID = makeAppServerReviewAttemptID() - let run = CodexReviewBackendModel.Review.Run( - attemptID: attemptID, - threadID: review.threadID.rawValue, - turnID: review.turnID.rawValue, - reviewThreadID: review.reviewThreadID.rawValue, - model: review.model ?? request.model + let startedReview = try await performReviewOperation(.startReview) { + try await startReviewSession(for: request) + } + let attempt: ReviewAttempt + do { + attempt = try Self.reviewAttempt( + for: startedReview.session, + attemptID: makeAppServerReviewAttemptID(), + fallbackModel: request.model + ) + } catch { + await discardInvalidlyIdentifiedReview(startedReview.session) + throw ReviewBackendFailure.protocolViolation( + message: "Review start returned invalid identity: \(error.localizedDescription)" + ) + } + let activeReview = ActiveReviewSession( + attempt: attempt, + session: startedReview.session, + chat: startedReview.chat ) - let session = AppServerReviewEventSession(run: run) - registerReviewEventSession(session, for: run) - await session.startConsuming(review) - - return await session.attempt() + activeReviewSessionsByAttemptID[attempt.attemptID] = activeReview + return backendAttempt(for: activeReview) } private func startReviewSession(for request: CodexReviewBackendModel.Review.Start) async throws - -> CodexReviewSession + -> CodexStartedReview { let workspace = URL(fileURLWithPath: request.request.cwd, isDirectory: true) return try await startDataKitBackedReviewSession(request, in: workspace) @@ -147,7 +118,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private func startDataKitBackedReviewSession( _ request: CodexReviewBackendModel.Review.Start, in workspace: URL - ) async throws -> CodexReviewSession { + ) async throws -> CodexStartedReview { try await modelContext.startReview( in: workspace, input: CodexReviewInput( @@ -156,7 +127,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { delivery: .inline ) ) - .session } private func reviewThreadOptions( @@ -179,43 +149,28 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } package func interruptReview( - _ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason + _ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason ) async throws { - guard abandonedReviewAttemptIDs.contains(run.attemptID) == false else { + guard abandonedReviewAttemptIDs.contains(attempt.attemptID) == false else { return } - let session = await reviewEventSession(for: run) - await session.requestCancellation(message: reason.message) - do { - _ = try await cancelReviewTurn(for: run) - await finishReviewEventStream( - threadID: run.threadID, - cancellationMessage: reason.message - ) - } catch { - await session.clearCancellationRequest() - throw error + _ = try await performReviewOperation(.interruptReview) { + try await cancelReviewTurn(for: attempt) } } package func prepareReviewRestart( - _ run: CodexReviewBackendModel.Review.Run + _ attempt: ReviewAttempt ) async throws -> CodexReviewBackendModel.Review.RestartToken { - guard let identity = run.appServerReviewIdentity else { - throw CodexReviewAPI.Error.io("Review run has no restartable app-server turn.") - } - let appServerToken = try await appServer.prepareReviewRestart(identity) - markAttemptAbandoned(run) - if let session = unregisterReviewEventSession(for: run) { - await session.abandon() - let metrics = await session.metricsSnapshot() - for threadID in localCleanupThreadIDs(for: run, additional: await session.cleanupThreadIDs()) { - completedReviewEventSessionMetricsByThreadID[threadID] = metrics - } + let identity = attempt.appServerReviewIdentity + let appServerToken = try await performReviewOperation(.prepareRestart) { + try await appServer.prepareReviewRestart(identity) } + markAttemptAbandoned(attempt) + activeReviewSessionsByAttemptID.removeValue(forKey: attempt.attemptID) return CodexReviewBackendModel.Review.RestartToken( id: appServerToken.id, - interruptedRun: run + interruptedAttempt: attempt ) } @@ -223,585 +178,654 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { - let interruptedRun = token.interruptedRun - guard let interruptedIdentity = interruptedRun.appServerReviewIdentity else { - throw CodexReviewAPI.Error.io("Prepared review restart has no app-server identity.") - } + let interruptedAttempt = token.interruptedAttempt + let interruptedIdentity = interruptedAttempt.appServerReviewIdentity let appServerToken = CodexReviewRestartToken( id: token.id, interruptedIdentity: interruptedIdentity ) - markRestartInFlight(forInterrupted: interruptedRun) + markRestartInFlight(forInterrupted: interruptedAttempt) defer { - clearRestartInFlight(forInterrupted: interruptedRun) + clearRestartInFlight(forInterrupted: interruptedAttempt) + } + let review = try await performReviewOperation(.restartReview) { + try await appServer.restartPreparedReview( + appServerToken, + target: request.request.target.appServerReviewTarget, + delivery: .inline, + threadOptions: reviewThreadOptions(model: interruptedAttempt.model ?? request.model) + ) + } + let recoveredAttempt: ReviewAttempt + do { + recoveredAttempt = try Self.reviewAttempt( + for: review, + attemptID: makeAppServerReviewAttemptID(), + sourceThreadID: interruptedAttempt.threadIdentity.sourceThreadID.rawValue, + fallbackModel: interruptedAttempt.model ?? request.model + ) + } catch { + await discardInvalidlyIdentifiedReview(review) + throw ReviewBackendFailure.protocolViolation( + message: "Review restart returned invalid identity: \(error.localizedDescription)" + ) } - let review = try await appServer.restartPreparedReview( - appServerToken, - target: request.request.target.appServerReviewTarget, - delivery: .inline, - threadOptions: reviewThreadOptions(model: interruptedRun.model ?? request.model) + let activeReview = ActiveReviewSession( + attempt: recoveredAttempt, + session: review, + chat: modelContext.model(for: review.activeTurnThreadID) ) - let attemptID = makeAppServerReviewAttemptID() - let recoveredRun = CodexReviewBackendModel.Review.Run( - attemptID: attemptID, - threadID: interruptedRun.threadID, - turnID: review.turnID.rawValue, - reviewThreadID: review.reviewThreadID.rawValue, - model: review.model ?? interruptedRun.model ?? request.model + activeReviewSessionsByAttemptID[recoveredAttempt.attemptID] = activeReview + return backendAttempt(for: activeReview) + } + + package func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + let interruptedAttempt = token.interruptedAttempt + let interruptedIdentity = interruptedAttempt.appServerReviewIdentity + let retainedIdentities = await appServer.discardPreparedReviewRestart( + CodexReviewRestartToken( + id: token.id, + interruptedIdentity: interruptedIdentity + ) ) - let session = AppServerReviewEventSession(run: recoveredRun) - registerReviewEventSession(session, for: recoveredRun) - await session.startConsuming(review) - - return await session.attempt() - } - - package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - var completedMetrics: ReviewBackendEventSessionMetrics? - var additionalCleanupThreadIDs: [String] = [] - if let session = unregisterReviewEventSession(for: run) { - await session.finish(cancellationMessage: nil) - let metrics = await session.metricsSnapshot() - additionalCleanupThreadIDs = await session.cleanupThreadIDs() - completedMetrics = metrics - } - let cleanupThreadIDs = localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs) - if let completedMetrics { - for threadID in cleanupThreadIDs { - completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics + + return retainedIdentities.map { identity in + // Do not turn this into a recoverable cross-source cleanup path. CodexKit + // keys token-owned retention by the token's source thread and constructs + // restarted identities from that same source handle. If this invariant is + // broken, assigning the identity to this run would transfer deletion + // authority across review lifecycles with no valid quarantine owner. + precondition( + identity.sourceThreadID.rawValue + == interruptedAttempt.threadIdentity.sourceThreadID.rawValue, + "A prepared review restart cannot transfer cleanup authority across source threads." + ) + if identity == interruptedIdentity { + return interruptedAttempt + } + do { + return try Self.reviewAttempt( + for: identity, + attemptID: makeAppServerReviewAttemptID(), + fallbackModel: interruptedAttempt.model + ) + } catch { + preconditionFailure( + "CodexKit returned an invalid retained review identity: \(error.localizedDescription)" + ) } - } - deferredThreadCleanupsByAttemptID[run.attemptID] = .init( - run: run, - additionalCleanupThreadIDs: additionalCleanupThreadIDs - ) - for threadID in cleanupThreadIDs { - reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) - activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) } } - private func flushDeferredThreadCleanups() async { - let deferredCleanups = deferredThreadCleanupsByAttemptID.values - deferredThreadCleanupsByAttemptID = [:] - for cleanup in deferredCleanups { - await cleanupAppServerReview( - cleanup.run, - additionalCleanupThreadIDs: cleanup.additionalCleanupThreadIDs + package func discardAllPreparedReviewRestarts( + ownedAttemptsByRunID: [ReviewRunID: ReviewAttempt] + ) async -> [ReviewRunID: [ReviewAttempt]] { + let retainedIdentitiesBySource = await appServer.discardAllPreparedReviewRestarts() + var retainedAttemptsByRunID: [ReviewRunID: [ReviewAttempt]] = [:] + + for retainedIdentities in retainedIdentitiesBySource.values { + let owners = ownedAttemptsByRunID.filter { _, attempt in + retainedIdentities.contains(attempt.appServerReviewIdentity) + } + precondition( + owners.count == 1, + "A prepared-restart identity group must match exactly one retained review run." ) + guard let (runID, owner) = owners.first else { + preconditionFailure( + "A prepared-restart identity group requires a retained review run." + ) + } + retainedAttemptsByRunID[runID] = retainedIdentities.map { identity in + if identity == owner.appServerReviewIdentity { + return owner + } + do { + return try Self.reviewAttempt( + for: identity, + attemptID: makeAppServerReviewAttemptID(), + fallbackModel: owner.model + ) + } catch { + preconditionFailure( + "CodexKit returned an invalid retained review identity: \(error.localizedDescription)" + ) + } + } } + return retainedAttemptsByRunID + } + + package func cleanupReview(_ attempt: ReviewAttempt) async { + finishReviewLocally(attempt) + } + + private func finishReviewLocally( + _ attempt: ReviewAttempt + ) { + activeReviewSessionsByAttemptID.removeValue(forKey: attempt.attemptID) + abandonedReviewAttemptIDs.remove(attempt.attemptID) + } + + package func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + guard let firstAttempt = attempts.first else { + return .init() + } + let sourceThreadID = firstAttempt.threadIdentity.sourceThreadID + precondition( + attempts.allSatisfy { $0.threadIdentity.sourceThreadID == sourceThreadID }, + "One retained review cleanup must contain a single source thread lifecycle." + ) + let result = await appServer.cleanupReview( + firstAttempt.appServerReviewIdentity, + additionalCleanupThreadIDs: attempts.dropFirst().map { attempt in + attempt.appServerReviewIdentity.cleanupThreadIDs + } + [additionalThreadIDs.map { CodexThreadID(rawValue: $0.rawValue) }] + ) + return .init(failures: result.failures.map { failure in + guard let threadID = try? ReviewThreadID(validating: failure.threadID.rawValue) else { + preconditionFailure("CodexKit returned an empty cleanup thread identity.") + } + return .init( + threadID: threadID, + message: failure.message + ) + }) } package func cleanupActiveReviewsForShutdown(_ request: CodexReviewRuntimeStopReviewCleanupRequest) async { - let runs = await activeReviewRunsForShutdown() - var cleanedAttemptIDs: Set = [] - for run in runs { + let attempts = await activeReviewAttemptsForShutdown() + var cleanedAttemptIDs: Set = [] + for attempt in attempts { if Task.isCancelled { return } - try? await interruptReview(run, reason: request.reason) + try? await interruptReview(attempt, reason: request.reason) if Task.isCancelled { return } - await cleanupReview(run) - cleanedAttemptIDs.insert(run.attemptID) + await cleanupReview(attempt) + cleanedAttemptIDs.insert(attempt.attemptID) } - for run in request.recoveryWaitingRuns where cleanedAttemptIDs.insert(run.attemptID).inserted { - if isRestartInFlight(forInterrupted: run) { + for attempt in request.recoveryWaitingAttempts + where cleanedAttemptIDs.insert(attempt.attemptID).inserted { + if isRestartInFlight(forInterrupted: attempt) { continue } if Task.isCancelled { return } - await cleanupReview(run) + await cleanupReview(attempt) } - await flushDeferredThreadCleanups() } - package func reviewEventSessionMetricsForTesting( - threadID: String - ) async -> ReviewBackendEventSessionMetrics? { - if let session = reviewEventSession(forThreadID: threadID) { - return await session.metricsSnapshot() - } - return completedReviewEventSessionMetricsByThreadID[threadID] - } - - package func activeReviewEventStreamSubscriptionIDForTesting(threadID: String) async -> Int? { - guard let session = reviewEventSession(forThreadID: threadID) else { - return nil + package func cleanupActiveReviewsAfterConnectionTermination( + _ request: CodexReviewRuntimeStopReviewCleanupRequest + ) async { + let activeAttempts = await activeReviewAttemptsForShutdown() + var attemptsByID = Dictionary(uniqueKeysWithValues: activeAttempts.map { ($0.attemptID, $0) }) + for attempt in request.recoveryWaitingAttempts { + attemptsByID[attempt.attemptID] = attempt } - return await session.activeStreamSubscriptionIDForTesting() - } - package func detachReviewEventStreamForTesting(threadID: String, subscriptionID: Int) async { - guard let session = reviewEventSession(forThreadID: threadID) else { - return + for attempt in attemptsByID.values { + finishReviewLocally(attempt) } - await session.detach(subscriptionID: subscriptionID) } - package func reviewAttemptForTesting(_ run: CodexReviewBackendModel.Review.Run) async -> BackendReviewAttempt { - let session = await reviewEventSession(for: run) - return await session.attempt() + private func backendAttempt(for activeReview: ActiveReviewSession) -> BackendReviewAttempt { + let attempt = activeReview.attempt + return BackendReviewAttempt( + attempt: activeReview.attempt, + observeTerminal: { [self] in + try await observeTerminal(for: attempt) + }, + observedTerminalIfKnown: { [self] in + await observedTerminalIfKnown(for: attempt) + }, + finalizeTerminal: { [self] observed in + await finalizeTerminal(observed, attempt: attempt) + } + ) } - private func reviewEventSession(for run: CodexReviewBackendModel.Review.Run) async -> AppServerReviewEventSession { - if let session = reviewEventSessionsByAttemptID[run.attemptID] { - await session.updateRun(run) - registerReviewEventSession(session, for: run) - return session + private func observeTerminal( + for attempt: ReviewAttempt + ) async throws -> ReviewBackendObservedTerminal { + guard let activeReview = activeReviewSessionsByAttemptID[attempt.attemptID], + activeReview.attempt == attempt else { + return .failed(.protocolViolation( + message: "Terminal observation requires the active SDK review session." + )) + } + do { + return AppServerReviewTerminalMapper.observed( + try await activeReview.session.collect(), + expectedAttempt: attempt + ) + } catch is CancellationError { + throw CancellationError() + } catch { + return .failed(AppServerReviewTerminalMapper.failure(error)) } - let session = AppServerReviewEventSession(run: run) - registerReviewEventSession(session, for: run) - return session } - private func registerReviewEventSession( - _ session: AppServerReviewEventSession, - for run: CodexReviewBackendModel.Review.Run - ) { - reviewEventSessionsByAttemptID[run.attemptID] = session - let activeThreadIDs = Set(run.appServerAssociatedThreadIDs) - for threadID in activeThreadIDsByAttemptID[run.attemptID] ?? [] - where activeThreadIDs.contains(threadID) == false { - if activeReviewAttemptIDByThreadID[threadID] == run.attemptID { - activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) + private func observedTerminalIfKnown( + for attempt: ReviewAttempt + ) async -> ReviewBackendObservedTerminal? { + guard let activeReview = activeReviewSessionsByAttemptID[attempt.attemptID], + activeReview.attempt == attempt else { + return .failed(.protocolViolation( + message: "Terminal probing requires the active SDK review session." + )) + } + do { + guard let outcome = try await activeReview.session.terminalOutcomeIfKnown() else { + return nil } + return AppServerReviewTerminalMapper.observed( + outcome, + expectedAttempt: attempt + ) + } catch is CancellationError { + return nil + } catch { + return .failed(AppServerReviewTerminalMapper.failure(error)) + } + } + + private func finalizeTerminal( + _ observed: ReviewBackendObservedTerminal, + attempt: ReviewAttempt + ) async -> ReviewBackendTerminal { + switch observed { + case .completed(let candidate): + // Do not directly await the refresh in the caller's task. Product + // cancellation removes its waiter, but an accepted completion must + // finish the authoritative DataKit publication barrier. + return await Task { [self] in + await publishCompletedReview(candidate, attempt: attempt) + }.value + case .interrupted(let message): + return .interrupted(message: message) + case .failed(let failure): + return .failed(failure) + } + } + + private func publishCompletedReview( + _ candidate: ReviewCompletionCandidate, + attempt: ReviewAttempt + ) async -> ReviewBackendTerminal { + guard candidate.turnID == attempt.turnID else { + return .failed(.protocolViolation( + message: "Review completion candidate does not match its attempt turn." + )) + } + guard let activeReview = activeReviewSessionsByAttemptID[attempt.attemptID], + activeReview.attempt == attempt else { + return .failed(.protocolViolation( + message: "Output publication requires the active SDK review session." + )) + } + let chat = activeReview.chat + do { + try await modelContext.refresh(chat, includeTurns: true) + } catch { + return .failed(.outputPublication(.refreshFailed( + turnID: candidate.turnID, + message: error.localizedDescription + ))) } - activeThreadIDsByAttemptID[run.attemptID] = activeThreadIDs - for threadID in activeThreadIDs { - activeReviewAttemptIDByThreadID[threadID] = run.attemptID + + let turnID = CodexTurnID(rawValue: candidate.turnID.rawValue) + guard chat.turn(id: turnID) != nil else { + return .failed(.outputPublication(.unavailable(turnID: candidate.turnID))) } - reviewEventSessionCanonicalThreadIDByThreadID[run.threadID] = run.threadID - if let reviewThreadID = run.reviewThreadID, - reviewThreadID != run.threadID - { - reviewEventSessionCanonicalThreadIDByThreadID[reviewThreadID] = run.threadID + guard let rawOutput = chat.transcript(in: turnID).reviewOutputText else { + return .failed(.outputPublication(.empty(turnID: candidate.turnID))) } - } - - private func reviewEventSession(forThreadID threadID: String) -> AppServerReviewEventSession? { - let canonicalThreadID = reviewEventSessionCanonicalThreadIDByThreadID[threadID] ?? threadID - let attemptID: String? - if let directAttemptID = activeReviewAttemptIDByThreadID[threadID] { - attemptID = directAttemptID - } else if canonicalThreadID == threadID { - attemptID = activeReviewAttemptIDByThreadID[canonicalThreadID] - } else { - attemptID = nil + guard let projectedOutput = try? NonEmptyReviewOutput(validating: rawOutput) else { + return .failed(.outputPublication(.empty(turnID: candidate.turnID))) } - guard let attemptID else { return nil } - return reviewEventSessionsByAttemptID[attemptID] + guard projectedOutput == candidate.expectedOutput else { + return .failed(.outputPublication(.mismatched(turnID: candidate.turnID))) + } + return .completed(.init(finalReview: projectedOutput)) } - private func unregisterReviewEventSession(for run: CodexReviewBackendModel.Review.Run) - -> AppServerReviewEventSession? - { - let threadIDs = - activeThreadIDsByAttemptID.removeValue(forKey: run.attemptID) - ?? Set(run.appServerAssociatedThreadIDs) - for threadID in threadIDs { - if activeReviewAttemptIDByThreadID[threadID] == run.attemptID { - activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) + private func discardInvalidlyIdentifiedReview( + _ session: CodexReviewSession + ) async { + await Task { [appServer] in + do { + _ = try await session.cancel() + } catch { + appServerBackendLogger.error( + "Failed to cancel a review with invalid identity before cleanup: \(error.localizedDescription, privacy: .public)" + ) } + await appServer.cleanupReview(session.identity) + }.value + } + + private func performReviewOperation( + _ operation: ReviewBackendOperationFailure.Operation, + _ body: () async throws -> T + ) async throws -> T { + do { + return try await body() + } catch is CancellationError { + throw CancellationError() + } catch let failure as ReviewBackendFailure { + throw failure + } catch let error as CodexAppServerError { + throw AppServerReviewOperationFailureMapper.failure(error, operation: operation) + } catch { + appServerBackendLogger.error( + "Unexpected review operation failure type \(String(reflecting: type(of: error)), privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + throw ReviewBackendFailure.protocolViolation( + message: "Unexpected review operation failure: \(error.localizedDescription)" + ) } - return reviewEventSessionsByAttemptID.removeValue(forKey: run.attemptID) } - private func markAttemptAbandoned(_ run: CodexReviewBackendModel.Review.Run) { - abandonedReviewAttemptIDs.insert(run.attemptID) + private nonisolated static func reviewAttempt( + for session: CodexReviewSession, + attemptID: String, + sourceThreadID: String? = nil, + fallbackModel: String? + ) throws -> ReviewAttempt { + try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: sourceThreadID ?? session.sourceThreadID.rawValue, + activeTurnThreadID: session.activeTurnThreadID.rawValue, + turnID: session.turnID.rawValue, + model: session.model ?? fallbackModel + ) } - private func markRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) { - inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] += 1 + private nonisolated static func reviewAttempt( + for identity: CodexReviewIdentity, + attemptID: String, + fallbackModel: String? + ) throws -> ReviewAttempt { + try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: identity.sourceThreadID.rawValue, + activeTurnThreadID: identity.activeTurnThreadID.rawValue, + turnID: identity.turnID.rawValue, + model: identity.model ?? fallbackModel + ) } - private func clearRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) { - let count = inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] - if count <= 1 { - inFlightRestartCountByInterruptedAttemptID.removeValue(forKey: run.attemptID) - } else { - inFlightRestartCountByInterruptedAttemptID[run.attemptID] = count - 1 - } + private func markAttemptAbandoned(_ attempt: ReviewAttempt) { + abandonedReviewAttemptIDs.insert(attempt.attemptID) } - private func isRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) -> Bool { - inFlightRestartCountByInterruptedAttemptID[run.attemptID] != nil + private func markRestartInFlight(forInterrupted attempt: ReviewAttempt) { + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID, default: 0] += 1 } - private func activeReviewRunsForShutdown() async -> [CodexReviewBackendModel.Review.Run] { - let sessions = Array(reviewEventSessionsByAttemptID.values) - var runsByAttemptID: [String: CodexReviewBackendModel.Review.Run] = [:] - for session in sessions { - let run = await session.currentRun() - runsByAttemptID[run.attemptID] = run + private func clearRestartInFlight(forInterrupted attempt: ReviewAttempt) { + let count = inFlightRestartCountByInterruptedAttemptID[attempt.attemptID, default: 0] + if count <= 1 { + inFlightRestartCountByInterruptedAttemptID.removeValue(forKey: attempt.attemptID) + } else { + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID] = count - 1 } - return Array(runsByAttemptID.values) } - private func finishReviewEventStream( - threadID: String, - cancellationMessage: String? - ) async { - guard let session = reviewEventSession(forThreadID: threadID) else { - return - } - await session.finish(cancellationMessage: cancellationMessage) + private func isRestartInFlight(forInterrupted attempt: ReviewAttempt) -> Bool { + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID] != nil + } + + private func activeReviewAttemptsForShutdown() async -> [ReviewAttempt] { + activeReviewSessionsByAttemptID.values.map(\.attempt) } private func cancelReviewTurn( - for run: CodexReviewBackendModel.Review.Run + for attempt: ReviewAttempt ) async throws -> CodexTurnCancellation { - guard let identity = run.appServerReviewIdentity else { - throw CodexReviewAPI.Error.io("Review run has no cancellable app-server turn.") - } - if let session = reviewEventSessionsByAttemptID[run.attemptID], - let cancellation = try await session.cancelReview( - expectedTurnID: identity.turnID.rawValue + guard let activeReview = activeReviewSessionsByAttemptID[attempt.attemptID], + activeReview.attempt == attempt else { + throw ReviewBackendFailure.protocolViolation( + message: "Interrupt requires the active SDK review session for its attempt." ) - { - return cancellation - } - let review = try await appServer.resumeReview( - identity, - threadOptions: reviewThreadOptions(model: run.model) - ) - return try await review.cancel() - } - - private func localCleanupThreadIDs( - for run: CodexReviewBackendModel.Review.Run, - additional: [String] - ) -> [String] { - let sourceThreadID = run.threadID - var seen: Set = [] - var threadIDs: [String] = [] - for sequence in [run.appServerCleanupThreadIDs, additional] { - for threadID in sequence where threadID != sourceThreadID && seen.insert(threadID).inserted { - threadIDs.append(threadID) - } } - if seen.insert(sourceThreadID).inserted { - threadIDs.append(sourceThreadID) - } - return threadIDs + return try await activeReview.session.cancel() } private func cleanupAppServerReview( - _ run: CodexReviewBackendModel.Review.Run, - additionalCleanupThreadIDs: [String] + _ attempt: ReviewAttempt ) async { - guard let identity = run.appServerReviewIdentity else { - for threadID in localCleanupThreadIDs(for: run, additional: additionalCleanupThreadIDs) { - try? await appServer.deleteThread(.init(rawValue: threadID)) - } - return - } - await appServer.cleanupReview( - identity, - additionalCleanupThreadIDs: [additionalCleanupThreadIDs.map(CodexThreadID.init(rawValue:))] - ) + await appServer.cleanupReview(attempt.appServerReviewIdentity) } } -private struct AppServerTypedReviewEvent: Sendable { - var events: [CodexReviewBackendModel.Review.Event] - var controlThreadID: String? - - init( - events: [CodexReviewBackendModel.Review.Event], - controlThreadID: String? = nil - ) { - self.events = events - self.controlThreadID = controlThreadID - } -} - -private enum AppServerTypedItemPhase { - case started - case updated - case completed -} - -private enum AppServerTypedReviewEventAdapter { - static func started( - review: CodexReviewSession, - run: CodexReviewBackendModel.Review.Run - ) -> AppServerTypedReviewEvent { - .init( - events: [ - .started( - turnID: review.turnID.rawValue, - reviewThreadID: review.reviewThreadID.rawValue, - model: run.model +enum AppServerReviewTerminalMapper { + static func observed( + _ outcome: CodexTurnOutcome, + expectedAttempt: ReviewAttempt + ) -> ReviewBackendObservedTerminal { + guard let turnID = try? ReviewTurnID(validating: outcome.response.turnID.rawValue) else { + return .failed(.protocolViolation(message: "Review terminal has an empty turn ID.")) + } + guard turnID == expectedAttempt.turnID else { + return .failed(.protocolViolation( + message: "Review terminal turn does not match its attempt." + )) + } + switch outcome { + case .completed(let response): + guard let rawOutput = response.transcript.reviewOutputText, + let output = try? NonEmptyReviewOutput(validating: rawOutput) else { + return .failed(.missingReviewOutput(turnID: turnID)) + } + return .completed(.init(turnID: turnID, expectedOutput: output)) + case .interrupted: + return .interrupted(message: nil) + case .failed(let failedTurn): + return .failed(.turnFailed(turnFailure(failedTurn.error))) + case .invalidTerminalStatus(let rawStatus, let error, _): + return .failed( + .invalidTerminalStatus( + rawStatus: rawStatus, + turnID: turnID, + turnFailure: error.map(turnFailure) ) - ], - controlThreadID: review.reviewThreadID.rawValue - ) - } - - static func convert( - _ event: CodexReviewEvent, - review: CodexReviewSession - ) -> AppServerTypedReviewEvent { - let controlThreadID = review.reviewThreadID.rawValue - return switch event { - case .turnStarted: - .init(events: [], controlThreadID: controlThreadID) - case .turnCompleted(let response): - .init(events: terminalEvents(for: response), controlThreadID: controlThreadID) - case .turnFailed(_, let message): - .init(events: [.failed(message.nilIfEmpty ?? "Failed.")], controlThreadID: controlThreadID) - case .itemStarted(let item, _): - .init(events: itemEvents(item, phase: .started), controlThreadID: controlThreadID) - case .itemUpdated(let item, _): - .init(events: itemEvents(item, phase: .updated), controlThreadID: controlThreadID) - case .itemCompleted(let item, _): - .init(events: itemEvents(item, phase: .completed), controlThreadID: controlThreadID) - case .message, - .messageDelta: - .init(events: [], controlThreadID: controlThreadID) - case .reasoningSummaryPartAdded: - .init(events: [], controlThreadID: controlThreadID) - case .reasoningDelta: - .init(events: [], controlThreadID: controlThreadID) - case .tokenUsageUpdated: - .init(events: [], controlThreadID: controlThreadID) - case .statusChanged(.idle), .statusChanged(.active(activeFlags: _)): - .init(events: [], controlThreadID: controlThreadID) - case .statusChanged(.notLoaded): - .init(events: [.failed("Review thread is no longer loaded.")], controlThreadID: controlThreadID) - case .statusChanged(.systemError): - .init(events: [.failed("Review thread has a system error.")], controlThreadID: controlThreadID) - case .statusChanged(.unknown(let status)): - .init( - events: unknownStatusEvents(status, turnID: review.turnID.rawValue), - controlThreadID: controlThreadID ) - case .closed: - .init(events: [.failed("Review thread closed.")], controlThreadID: controlThreadID) - case .unknown(let raw): - .init(events: unknownEvents(raw), controlThreadID: controlThreadID) } } - private static func terminalEvents( - for response: CodexResponse - ) -> [CodexReviewBackendModel.Review.Event] { - if let message = response.errorMessage?.nilIfEmpty { - return terminalFailureEvents(status: response.status, message: message) + static func failure(_ error: any Error) -> ReviewBackendFailure { + if let failure = error as? ReviewBackendFailure { + return failure } - if response.status?.isFailure == true { - return terminalFailureEvents( - status: response.status, - message: response.status?.rawValue ?? "Failed." + guard let appServerError = error as? CodexAppServerError else { + appServerBackendLogger.error( + "Unexpected review terminal failure type \(String(reflecting: type(of: error)), privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return .protocolViolation( + message: "Unexpected review terminal failure: \(error.localizedDescription)" ) } - guard let finalReview = reviewCompletionText(for: response) else { - return [.failed("Review completed without review output.")] + switch appServerError { + case .connectionTerminated(let termination): + return .connectionTerminated(AppServerReviewOperationFailureMapper.connectionTermination(termination)) + case .malformedNotification(let malformed): + return .protocolViolation(message: malformed.localizedDescription) + case .turnDeadlineExceeded(let turnID, let duration): + return .protocolViolation( + message: "Unexpected terminal collection deadline for turn \(turnID.rawValue): \(duration)" + ) + case .launch, .request, .reviewRestartUnavailable, .loginAlreadyInProgress: + return .protocolViolation( + message: "Unexpected terminal collection failure: \(appServerError.localizedDescription)" + ) } - return [ - .completed(finalReview: finalReview) - ] - } - - private static func reviewCompletionText(for response: CodexResponse) -> String? { - response.transcript.reviewOutputText?.nilIfEmpty - ?? response.finalAnswer?.nilIfEmpty - ?? response.transcript.finalAnswer?.nilIfEmpty } - private static func unknownStatusEvents( - _: String, - turnID _: String - ) -> [CodexReviewBackendModel.Review.Event] { - [] - } - - private static func terminalFailureEvents( - status: CodexTurnStatus?, - message: String - ) -> [CodexReviewBackendModel.Review.Event] { - switch status { - case .interrupted, .cancelled: - [.cancelled(message)] - case .failed, .running, .completed, .unknown, nil: - [.failed(message)] - } + static func turnFailure(_ error: CodexTurnError) -> ReviewTurnFailure { + .init( + message: error.message, + code: error.info.map(turnFailureCode), + additionalDetails: error.additionalDetails + ) } - private static func itemEvents( - _ item: CodexThreadItem, - phase: AppServerTypedItemPhase - ) -> [CodexReviewBackendModel.Review.Event] { - guard item.kind.rawValue != "enteredReviewMode" else { - return [] + private static func turnFailureCode(_ info: CodexErrorInfo) -> ReviewTurnFailure.Code { + switch info { + case .contextWindowExceeded: + .contextWindowExceeded + case .sessionBudgetExceeded: + .sessionBudgetExceeded + case .usageLimitExceeded: + .usageLimitExceeded + case .serverOverloaded: + .serverOverloaded + case .cyberPolicy: + .cyberPolicy + case .httpConnectionFailed(let status): + .httpConnectionFailed(status: status) + case .responseStreamConnectionFailed(let status): + .responseStreamConnectionFailed(status: status) + case .internalServerError: + .internalServerError + case .unauthorized: + .unauthorized + case .badRequest: + .badRequest + case .threadRollbackFailed: + .threadRollbackFailed + case .sandboxError: + .sandboxError + case .responseStreamDisconnected(let status): + .responseStreamDisconnected(status: status) + case .responseTooManyFailedAttempts(let status): + .responseTooManyFailedAttempts(status: status) + case .activeTurnNotSteerable(let kind): + .activeTurnNotSteerable(kind: kind) + case .other: + .other + case .unknown(let rawValue): + .unknown(rawValue: rawValue) } - return [] - } - - private static func unknownEvents( - _: CodexRawNotification - ) -> [CodexReviewBackendModel.Review.Event] { - [] } } -private actor AppServerReviewEventSession { - private let pipeline: ReviewBackendEventSession - private var typedReviewStreamTask: Task? - private var reviewSession: CodexReviewSession? - - init( - run: CodexReviewBackendModel.Review.Run, - mailbox: BackendReviewEventMailbox = .init() - ) { - self.pipeline = ReviewBackendEventSession( - run: run, - mailbox: mailbox, - callbacks: .init( - recordFinished: { run, metrics in - appServerBackendLogger.debug( - "Review event session finished for \(run.threadID, privacy: .public): emitted=\(metrics.emitted, privacy: .public) buffered=\(metrics.buffered, privacy: .public) ignored=\(metrics.ignored, privacy: .public) timeoutWarnings=\(metrics.commandTimeoutWarnings, privacy: .public)" - ) - } +private enum AppServerReviewOperationFailureMapper { + static func failure( + _ error: CodexAppServerError, + operation: ReviewBackendOperationFailure.Operation + ) -> ReviewBackendFailure { + let reason: ReviewBackendOperationFailure.Reason + switch error { + case .launch(let failure): + reason = .launch(launchKind(failure)) + case .request(let failure): + reason = .request( + requestID: failure.requestID, + method: failure.method, + kind: requestKind(failure.kind) ) - ) - } - - func updateRun(_ run: CodexReviewBackendModel.Review.Run) async { - await pipeline.updateRun(run) - } - - func currentRun() async -> CodexReviewBackendModel.Review.Run { - await pipeline.currentRun() - } - - func attempt() async -> BackendReviewAttempt { - await pipeline.attempt() - } - - func cleanupThreadIDs() async -> [String] { - await pipeline.cleanupThreadIDs() - } - - func requestCancellation(message: String) async { - await pipeline.requestCancellation(message: message) - } - - func clearCancellationRequest() async { - await pipeline.clearCancellationRequest() - } - - func finish(cancellationMessage: String?) async { - cancelTypedReviewStream() - await pipeline.finish(cancellationMessage: cancellationMessage) - } - - func finish(throwing error: (any Error)?) async { - cancelTypedReviewStream() - await pipeline.finish(throwing: error) - } - - func abandon() async { - cancelTypedReviewStream() - await pipeline.abandon() - } - - func metricsSnapshot() async -> ReviewBackendEventSessionMetrics { - await pipeline.metricsSnapshot() - } - - func activeStreamSubscriptionIDForTesting() -> Int? { - nil - } - - func detach(subscriptionID _: Int) {} - - func startConsuming(_ review: CodexReviewSession) { - guard typedReviewStreamTask == nil else { - return - } - reviewSession = review - typedReviewStreamTask = Task { [weak self] in - await self?.consume(review) - } - } - - func cancelReview( - expectedTurnID _: String - ) async throws -> CodexTurnCancellation? { - guard let reviewSession else { - return nil - } - return try await reviewSession.cancel() - } - - private func consume(_ review: CodexReviewSession) async { - defer { - typedReviewStreamTask = nil - if reviewSession?.id == review.id { - reviewSession = nil + case .connectionTerminated(let termination): + reason = .connectionTerminated(connectionTermination(termination)) + case .turnDeadlineExceeded(let turnID, let duration): + guard let reviewTurnID = try? ReviewTurnID(validating: turnID.rawValue) else { + return .protocolViolation(message: "Review operation deadline has an empty turn ID.") } + reason = .turnDeadlineExceeded(turnID: reviewTurnID, duration: duration) + case .malformedNotification(let malformed): + reason = .malformedNotification(method: malformed.method) + case .reviewRestartUnavailable: + reason = .reviewRestartUnavailable + case .loginAlreadyInProgress: + return .protocolViolation( + message: "A review operation unexpectedly reported an authentication conflict." + ) } - let run = await pipeline.currentRun() - await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run)) - do { - for try await event in review.events { - if Task.isCancelled { - return - } - await receive(AppServerTypedReviewEventAdapter.convert(event, review: review)) - } - await finish(throwing: nil) - } catch is CancellationError { - await finish(throwing: CancellationError()) - } catch { - await finish(throwing: error) + return .operation(.init( + operation: operation, + reason: reason, + message: error.localizedDescription + )) + } + + static func connectionTermination( + _ termination: CodexConnectionTermination + ) -> ReviewBackendConnectionTermination { + switch termination { + case .closedByCaller: + .closed + case .transportFailure(let failure): + .transport(message: failure.localizedDescription) + case .processExited(let status): + .processExited(status: status) + } + } + + private static func launchKind( + _ failure: CodexLaunchFailure + ) -> ReviewBackendOperationFailure.LaunchKind { + switch failure { + case .executableNotFound: + .executableNotFound + case .scaffold: + .scaffold + case .spawn: + .spawn + } + } + + private static func requestKind( + _ kind: CodexRequestFailure.Kind + ) -> ReviewBackendOperationFailure.RequestKind { + switch kind { + case .encode: + .encode + case .write: + .write + case .transport: + .transport + case .server(let error): + .server( + code: error.code, + turnFailure: error.turnError.map(AppServerReviewTerminalMapper.turnFailure) + ) + case .invalidResponse(let expectedType, _, _): + .invalidResponse(expectedType: expectedType) + case .deadlineExceeded: + .deadlineExceeded + case .overloadRetryExhausted(let last, let attempts): + .overloadRetryExhausted( + lastCode: last.code, + lastTurnFailure: last.turnError.map(AppServerReviewTerminalMapper.turnFailure), + attempts: attempts + ) } } - - private func receive(_ converted: AppServerTypedReviewEvent) async { - await pipeline.receive(converted.events, controlThreadID: converted.controlThreadID) - } - - private func cancelTypedReviewStream() { - typedReviewStreamTask?.cancel() - typedReviewStreamTask = nil - reviewSession = nil - } } -private extension CodexReviewBackendModel.Review.Run { - var appServerReviewIdentity: CodexReviewIdentity? { - guard let turnID = turnID?.nilIfEmpty else { - return nil - } - let sourceThreadID = CodexThreadID(rawValue: threadID) - let reviewThreadID = reviewThreadID?.nilIfEmpty.map(CodexThreadID.init(rawValue:)) +private extension ReviewAttempt { + var appServerReviewIdentity: CodexReviewIdentity { + let sourceThreadID = CodexThreadID(rawValue: threadIdentity.sourceThreadID.rawValue) + let activeTurnThreadID = CodexThreadID(rawValue: threadIdentity.activeTurnThreadID.rawValue) return CodexReviewIdentity( threadID: sourceThreadID, - turnID: .init(rawValue: turnID), - reviewThreadID: reviewThreadID == sourceThreadID ? nil : reviewThreadID, + turnID: .init(rawValue: turnID.rawValue), + reviewThreadID: activeTurnThreadID == sourceThreadID ? nil : activeTurnThreadID, model: model ) } - var appServerAssociatedThreadIDs: [String] { - if let identity = appServerReviewIdentity { - return identity.associatedThreadIDs.map(\.rawValue) - } - return [threadID] - } - - var appServerCleanupThreadIDs: [String] { - if let identity = appServerReviewIdentity { - return identity.cleanupThreadIDs.map(\.rawValue) - } - return [threadID] - } } private extension CodexAppServerKit.CodexAccount { @@ -844,36 +868,3 @@ private extension CodexModel { ) } } - -private extension CodexChatGPTLogin { - func backendChallenge() -> CodexReviewBackendModel.Login.Challenge { - .init( - id: id.rawValue, - verificationURL: authenticationURL, - nativeWebAuthenticationCallbackScheme: nativeWebAuthentication?.callbackURLScheme - ) - } -} - -private extension CodexLoginHandle { - func backendChallenge( - nativeWebAuthenticationCallbackScheme: String? - ) throws -> CodexReviewBackendModel.Login.Challenge { - switch self { - case .apiKey: - return .init(id: "api-key") - case .chatGPT(let loginID, let authenticationURL): - return .init( - id: loginID.rawValue, - verificationURL: authenticationURL, - nativeWebAuthenticationCallbackScheme: nativeWebAuthenticationCallbackScheme - ) - case .chatGPTDeviceCode(let loginID, let verificationURL, let userCode): - return .init( - id: loginID.rawValue, - verificationURL: verificationURL, - userCode: userCode - ) - } - } -} diff --git a/Sources/CodexReviewHost/AccountRegistryDisk.swift b/Sources/CodexReviewHost/AccountRegistryDisk.swift new file mode 100644 index 00000000..924b778a --- /dev/null +++ b/Sources/CodexReviewHost/AccountRegistryDisk.swift @@ -0,0 +1,1736 @@ +import CryptoKit +import Darwin +import Foundation +import OSLog +import CodexReviewKit + +private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") + +extension AccountRegistryStore { + enum Disk { + private static let filesystemRootURL = URL(fileURLWithPath: "/", isDirectory: true) + + private struct Registry: Codable { + static let currentSchemaVersion = 1 + + var schemaVersion: Int + var generation: UInt64 + var contentHash: String + var activeAccountKey: String? + var accounts: [Entry] + + enum CodingKeys: String, CodingKey { + case schemaVersion + case generation + case contentHash + case activeAccountKey + case accounts + } + + init( + schemaVersion: Int = currentSchemaVersion, + generation: UInt64 = 0, + contentHash: String = "", + activeAccountKey: String?, + accounts: [Entry] + ) { + self.schemaVersion = schemaVersion + self.generation = generation + self.contentHash = contentHash + self.activeAccountKey = activeAccountKey + self.accounts = accounts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 0 + generation = try container.decodeIfPresent(UInt64.self, forKey: .generation) ?? 0 + contentHash = try container.decodeIfPresent(String.self, forKey: .contentHash) ?? "" + activeAccountKey = try container.decodeIfPresent(String.self, forKey: .activeAccountKey) + accounts = try container.decode([Entry].self, forKey: .accounts) + } + } + + private struct Entry: Codable { + var accountKey: String? + var immutableRevision: String? + var kind: Kind + var email: String + var planType: String? + var lastActivatedAt: Date? + var lastRateLimitFetchAt: Date? + var lastRateLimitError: String? + var cachedRateLimits: [SavedRateLimitWindow]? + + enum CodingKeys: String, CodingKey { + case accountKey + case immutableRevision + case kind + case email + case planType + case lastActivatedAt + case lastRateLimitFetchAt + case lastRateLimitError + case cachedRateLimits + } + + init( + accountKey: String?, + immutableRevision: String? = nil, + kind: Kind, + email: String, + planType: String?, + lastActivatedAt: Date?, + lastRateLimitFetchAt: Date?, + lastRateLimitError: String?, + cachedRateLimits: [SavedRateLimitWindow]? + ) { + self.accountKey = accountKey + self.immutableRevision = immutableRevision + self.kind = kind + self.email = email + self.planType = planType + self.lastActivatedAt = lastActivatedAt + self.lastRateLimitFetchAt = lastRateLimitFetchAt + self.lastRateLimitError = lastRateLimitError + self.cachedRateLimits = cachedRateLimits + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accountKey = try container.decodeIfPresent(String.self, forKey: .accountKey) + self.immutableRevision = try container.decodeIfPresent(String.self, forKey: .immutableRevision) + self.email = try container.decode(String.self, forKey: .email) + // Registries written before the kind field existed must keep + // decoding; dropping them would empty the persisted account list. + self.kind = try container.decodeIfPresent(Kind.self, forKey: .kind) + ?? Kind.legacyDefault(accountKey: accountKey, email: email) + self.planType = try container.decodeIfPresent(String.self, forKey: .planType) + self.lastActivatedAt = try container.decodeIfPresent(Date.self, forKey: .lastActivatedAt) + self.lastRateLimitFetchAt = try container.decodeIfPresent(Date.self, forKey: .lastRateLimitFetchAt) + self.lastRateLimitError = try container.decodeIfPresent(String.self, forKey: .lastRateLimitError) + self.cachedRateLimits = try container.decodeIfPresent( + [SavedRateLimitWindow].self, + forKey: .cachedRateLimits + ) + } + } + + private enum Kind: String, Codable { + case chatGPT = "chatgpt" + case apiKey + case amazonBedrock + + static func legacyDefault(accountKey: String?, email: String) -> Self { + let normalizedAccountKey = accountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { $0.isEmpty ? nil : $0 } + switch normalizedAccountKey ?? CodexReviewAccount.normalizedEmail(email) { + case "api-key": + return .apiKey + case "amazon-bedrock": + return .amazonBedrock + default: + return .chatGPT + } + } + + init(_ accountKind: CodexReviewBackendModel.Account.Kind) { + switch accountKind { + case .chatGPT: + self = .chatGPT + case .apiKey: + self = .apiKey + case .amazonBedrock: + self = .amazonBedrock + } + } + + var accountKind: CodexReviewBackendModel.Account.Kind { + switch self { + case .chatGPT: + .chatGPT + case .apiKey: + .apiKey + case .amazonBedrock: + .amazonBedrock + } + } + + } + + private struct SavedRateLimitWindow: Codable { + var windowDurationMinutes: Int + var usedPercent: Int + var resetsAt: Date? + + var tuple: (windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?) { + (windowDurationMinutes, usedPercent, resetsAt) + } + } + + private struct MutationJournal: Codable { + enum Phase: String, Codable { + case prepared + case sharedAuthApplied + case registryCommitted + } + + enum SharedAuthAction: String, Codable { + case replace + case remove + } + + var id: UUID + var phase: Phase + var beforeRegistry: Registry + var desiredRegistry: Registry + var beforeSharedAuthFingerprint: String? + var desiredSharedAuthFingerprint: String? + var sharedAuthAction: SharedAuthAction + var replacementAccountKey: String? + var replacementRevision: String? + var mayApplyIrreversibleLogout: Bool + } + + private struct ReconciliationDebt: Codable { + enum Expectation: String, Codable { + case signedOut + case account + case observedAccount + case anyChatGPT + case cancelOutcomeUnknown + case reconcileCurrentRuntime + } + + var expectation: Expectation + var accountKey: String? + var provider: ExpectedRuntimeAccount.Provider? + var message: String + var recordedAt: Date + + var expectedRuntimeAccount: ExpectedRuntimeAccount { + switch expectation { + case .signedOut: + return .signedOut + case .account: + guard let accountKey else { + preconditionFailure("An account reconciliation debt requires its expected account key.") + } + return .account(accountKey) + case .observedAccount: + guard let accountKey, let provider else { + preconditionFailure("An observed account reconciliation debt requires identity and provider.") + } + return .observedAccount(accountKey: accountKey, provider: provider) + case .anyChatGPT: + return .anyChatGPT + case .cancelOutcomeUnknown: + return .cancelOutcomeUnknown(previousActiveAccountKey: accountKey) + case .reconcileCurrentRuntime: + return .reconcileCurrentRuntime + } + } + } + + private struct TemporaryHomeCleanupDebt: Codable { + var paths: [String] + } + + static func reserveTemporaryCodexHome( + kind: AccountRegistryStore.TemporaryCodexHomeKind, + codexHomeURL: URL + ) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(kind.pathPrefix)\(UUID().uuidString)", isDirectory: true) + .standardizedFileURL + try updateTemporaryHomeCleanupDebt( + adding: url.path, + codexHomeURL: codexHomeURL + ) + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + return url + } + + static func finishTemporaryCodexHome( + _ url: URL, + codexHomeURL: URL, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil + ) throws { + let standardizedURL = url.standardizedFileURL + let temporaryDirectory = FileManager.default.temporaryDirectory.standardizedFileURL + let permittedName = standardizedURL.lastPathComponent.hasPrefix("codex-review-auth-") + || standardizedURL.lastPathComponent.hasPrefix("codex-review-rate-limits-") + precondition( + standardizedURL.deletingLastPathComponent() == temporaryDirectory && permittedName, + "Only owned CodexReview temporary homes can enter cleanup debt." + ) + do { + try removeDurably( + at: standardizedURL, + directoryDurabilityDidSynchronize: directoryDurabilityDidSynchronize + ) + try updateTemporaryHomeCleanupDebt( + removing: standardizedURL.path, + codexHomeURL: codexHomeURL + ) + } catch { + try updateTemporaryHomeCleanupDebt( + adding: standardizedURL.path, + codexHomeURL: codexHomeURL + ) + logger.error( + "Credential-bearing temporary home cleanup remains pending: \(standardizedURL.path, privacy: .private(mask: .hash))" + ) + } + } + + private static func retryTemporaryHomeCleanup(codexHomeURL: URL) throws { + let debtURL = temporaryHomeCleanupDebtURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: debtURL.path) else { + return + } + let debt = try JSONDecoder().decode( + TemporaryHomeCleanupDebt.self, + from: Data(contentsOf: debtURL) + ) + for path in debt.paths { + let url = URL(fileURLWithPath: path, isDirectory: true) + try finishTemporaryCodexHome(url, codexHomeURL: codexHomeURL) + } + } + + private static func updateTemporaryHomeCleanupDebt( + adding path: String? = nil, + removing removedPath: String? = nil, + codexHomeURL: URL + ) throws { + let url = temporaryHomeCleanupDebtURL(codexHomeURL: codexHomeURL) + var paths: Set = [] + if FileManager.default.fileExists(atPath: url.path) { + paths = Set(try JSONDecoder().decode( + TemporaryHomeCleanupDebt.self, + from: Data(contentsOf: url) + ).paths) + } + if let path { + paths.insert(path) + } + if let removedPath { + paths.remove(removedPath) + } + if paths.isEmpty { + try removeDurably(at: url) + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try writeAtomically( + encoder.encode(TemporaryHomeCleanupDebt(paths: paths.sorted())), + to: url, + permissions: 0o600 + ) + } + + static func recordReconciliationDebt( + expectedAccount: ExpectedRuntimeAccount, + message: String, + codexHomeURL: URL, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil + ) throws { + let expectation: ReconciliationDebt.Expectation + let accountKey: String? + let provider: ExpectedRuntimeAccount.Provider? + switch expectedAccount { + case .signedOut: + expectation = .signedOut + accountKey = nil + provider = nil + case .account(let value): + expectation = .account + accountKey = CodexReviewAccount.normalizedEmail(value) + provider = nil + case .observedAccount(let value, let valueProvider): + expectation = .observedAccount + accountKey = CodexReviewAccount.normalizedEmail(value) + provider = valueProvider + case .anyChatGPT: + expectation = .anyChatGPT + accountKey = nil + provider = nil + case .cancelOutcomeUnknown(let previousActiveAccountKey): + expectation = .cancelOutcomeUnknown + accountKey = previousActiveAccountKey.map(CodexReviewAccount.normalizedEmail) + provider = nil + case .reconcileCurrentRuntime: + expectation = .reconcileCurrentRuntime + accountKey = nil + provider = nil + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try writeAtomically( + encoder.encode(ReconciliationDebt( + expectation: expectation, + accountKey: accountKey, + provider: provider, + message: message, + recordedAt: Date() + )), + to: reconciliationDebtURL(codexHomeURL: codexHomeURL), + permissions: 0o600, + directoryDurabilityDidSynchronize: directoryDurabilityDidSynchronize + ) + } + + static func reconciliationDebtExpectation( + codexHomeURL: URL + ) throws -> ExpectedRuntimeAccount? { + let url = reconciliationDebtURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + do { + let debt = try JSONDecoder().decode( + ReconciliationDebt.self, + from: Data(contentsOf: url) + ) + return debt.expectedRuntimeAccount + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account reconciliation debt is inconsistent: \(error.localizedDescription)" + ) + } + } + + static func clearReconciliationDebt(codexHomeURL: URL) throws { + let url = reconciliationDebtURL(codexHomeURL: codexHomeURL) + try removeDurably(at: url) + } + + static func load(codexHomeURL: URL) throws -> AccountRegistryStore.Snapshot { + try retryTemporaryHomeCleanup(codexHomeURL: codexHomeURL) + let registry = try loadRegistry(codexHomeURL: codexHomeURL) + do { + try garbageCollectOrphanedRevisions( + referencedBy: registry, + codexHomeURL: codexHomeURL + ) + } catch { + logger.error( + "Account registry cleanup remains pending and will retry on the next load: \(error.localizedDescription, privacy: .public)" + ) + } + return snapshot(from: registry) + } + + static func loadSnapshotWithoutMaintenance( + codexHomeURL: URL + ) throws -> AccountRegistryStore.Snapshot { + snapshot(from: try loadRegistry(codexHomeURL: codexHomeURL)) + } + + private static func snapshot( + from registry: Registry + ) -> AccountRegistryStore.Snapshot { + let accounts = registry.accounts.compactMap(makePayload(from:)) + let activeAccountKey = registry.activeAccountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { activeAccountKey in + accounts.contains(where: { $0.accountKey == activeAccountKey }) ? activeAccountKey : nil + } + logger.info("Loaded \(accounts.count, privacy: .public) persisted Codex review account(s)") + return .init(accounts: accounts, activeAccountKey: activeAccountKey) + } + + static func deactivateAccount( + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + var registry = try loadRegistry(codexHomeURL: codexHomeURL) + guard registry.activeAccountKey != nil else { + return + } + registry.activeAccountKey = nil + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + private static func mergedEntries( + _ accounts: [CodexSavedAccountPayload], + activeAccountKey: String?, + existing: [Entry] + ) -> [Entry] { + let existingByAccountKey = Dictionary(uniqueKeysWithValues: existing.compactMap { entry in + normalizedAccountKey(from: entry).map { ($0, entry) } + }) + return accounts.map { account in + var entry = existingByAccountKey[account.accountKey] ?? Entry( + accountKey: account.accountKey, + kind: .init(account.kind), + email: account.email, + planType: account.planType, + lastActivatedAt: nil, + lastRateLimitFetchAt: nil, + lastRateLimitError: nil, + cachedRateLimits: nil + ) + entry.accountKey = account.accountKey + entry.kind = .init(account.kind) + entry.email = account.email + entry.planType = account.planType + entry.cachedRateLimits = account.rateLimits.map { window in + .init( + windowDurationMinutes: window.windowDurationMinutes, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt + ) + } + entry.lastRateLimitFetchAt = account.lastRateLimitFetchAt + entry.lastRateLimitError = account.lastRateLimitError + if account.accountKey == activeAccountKey { + entry.lastActivatedAt = Date() + } + return entry + } + } + + static func prepareAccountActivation( + _ accountKey: String, + codexHomeURL: URL + ) throws -> AccountRegistryStore.PreparedMutation { + let targetAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + let beforeRegistry = try loadRegistry(codexHomeURL: codexHomeURL) + guard let entry = beforeRegistry.accounts.first(where: { + normalizedAccountKey(from: $0) == targetAccountKey + }), let revision = entry.immutableRevision, + let savedAuthURL = immutableAuthURL( + for: entry, + accountKey: targetAccountKey, + codexHomeURL: codexHomeURL + ) else { + throw CodexReviewAPI.Error.io("Saved authentication is missing for account \(targetAccountKey).") + } + let desiredAuthData = try validatedAuthData(at: savedAuthURL) + var desiredRegistry = beforeRegistry + desiredRegistry.activeAccountKey = targetAccountKey + if let index = desiredRegistry.accounts.firstIndex(where: { + normalizedAccountKey(from: $0) == targetAccountKey + }) { + desiredRegistry.accounts[index].lastActivatedAt = Date() + } + desiredRegistry = try nextRegistry(from: desiredRegistry) + let id = UUID() + let journal = MutationJournal( + id: id, + phase: .prepared, + beforeRegistry: beforeRegistry, + desiredRegistry: desiredRegistry, + beforeSharedAuthFingerprint: try sharedAuthFingerprint(codexHomeURL: codexHomeURL), + desiredSharedAuthFingerprint: fingerprint(desiredAuthData), + sharedAuthAction: .replace, + replacementAccountKey: targetAccountKey, + replacementRevision: revision, + mayApplyIrreversibleLogout: false + ) + try writeJournal(journal, codexHomeURL: codexHomeURL) + return .init(id: id) + } + + static func prepareIrreversibleRemoval( + accountKey: String, + codexHomeURL: URL + ) throws -> AccountRegistryStore.PreparedMutation { + let beforeRegistry = try loadRegistry(codexHomeURL: codexHomeURL) + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + guard beforeRegistry.accounts.contains(where: { + self.normalizedAccountKey(from: $0) == normalizedAccountKey + }) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Cannot prepare removal for missing account \(normalizedAccountKey)." + ) + } + var desiredRegistry = beforeRegistry + desiredRegistry.accounts.removeAll { + self.normalizedAccountKey(from: $0) == normalizedAccountKey + } + if desiredRegistry.activeAccountKey.map(CodexReviewAccount.normalizedEmail) == normalizedAccountKey { + desiredRegistry.activeAccountKey = nil + } + desiredRegistry = try nextRegistry(from: desiredRegistry) + let id = UUID() + try writeJournal( + .init( + id: id, + phase: .prepared, + beforeRegistry: beforeRegistry, + desiredRegistry: desiredRegistry, + beforeSharedAuthFingerprint: try sharedAuthFingerprint(codexHomeURL: codexHomeURL), + desiredSharedAuthFingerprint: nil, + sharedAuthAction: .remove, + replacementAccountKey: nil, + replacementRevision: nil, + mayApplyIrreversibleLogout: true + ), + codexHomeURL: codexHomeURL + ) + return .init(id: id) + } + + static func commitPreparedMutation( + _ mutation: AccountRegistryStore.PreparedMutation, + codexHomeURL: URL + ) throws { + var journal = try loadJournal(codexHomeURL: codexHomeURL) + guard journal.id == mutation.id else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The prepared account mutation token does not match the durable journal." + ) + } + do { + try forwardPreparedJournal(&journal, codexHomeURL: codexHomeURL) + } catch { + let originalError = error + do { + let durableJournalURL = journalURL(codexHomeURL: codexHomeURL) + if FileManager.default.fileExists(atPath: durableJournalURL.path) { + journal = try loadJournal(codexHomeURL: codexHomeURL) + guard journal.id == mutation.id else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The recovered account mutation token no longer matches its durable journal." + ) + } + try forwardPreparedJournal(&journal, codexHomeURL: codexHomeURL) + } else { + let registry = try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + guard sameRegistry(registry, journal.desiredRegistry) else { + throw originalError + } + } + } catch { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "The prepared account mutation could not forward-complete. " + + "Original failure: \(originalError.localizedDescription). " + + "Recovery failure: \(error.localizedDescription)" + ) + } + } + } + + private static func forwardPreparedJournal( + _ journal: inout MutationJournal, + codexHomeURL: URL + ) throws { + try applySharedAuthAction(journal, codexHomeURL: codexHomeURL) + journal.phase = .sharedAuthApplied + try writeJournal(journal, codexHomeURL: codexHomeURL) + try persistRegistry(journal.desiredRegistry, codexHomeURL: codexHomeURL) + journal.phase = .registryCommitted + try writeJournal(journal, codexHomeURL: codexHomeURL) + try removeJournal(codexHomeURL: codexHomeURL) + } + + static func abortPreparedMutation( + _ mutation: AccountRegistryStore.PreparedMutation, + codexHomeURL: URL + ) throws -> AccountRegistryStore.PreparedAbortDisposition { + let journal = try loadJournal(codexHomeURL: codexHomeURL) + guard journal.id == mutation.id else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The aborted account mutation token does not match the durable journal." + ) + } + let registry = try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + let sharedFingerprint = try sharedAuthFingerprint(codexHomeURL: codexHomeURL) + if sameRegistry(registry, journal.beforeRegistry), + sharedFingerprint == journal.beforeSharedAuthFingerprint { + try removeJournal(codexHomeURL: codexHomeURL) + return .restoredBefore(snapshot(from: journal.beforeRegistry)) + } + try recoverJournal(journal, codexHomeURL: codexHomeURL) + let recovered = try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + if sameRegistry(recovered, journal.beforeRegistry) { + return .restoredBefore(snapshot(from: recovered)) + } + if sameRegistry(recovered, journal.desiredRegistry) { + return .forwardedDesired(snapshot(from: recovered)) + } + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The aborted account mutation resolved to neither its before nor desired registry." + ) + } + + static func updateCachedRateLimits( + from account: CodexSavedAccountPayload, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + var registry = try loadRegistry(codexHomeURL: codexHomeURL) + guard let index = registry.accounts.firstIndex(where: { + normalizedAccountKey(from: $0) == account.accountKey + }) else { + return + } + registry.accounts[index].planType = account.planType + registry.accounts[index].cachedRateLimits = account.rateLimits.map { window in + .init( + windowDurationMinutes: window.windowDurationMinutes, + usedPercent: window.usedPercent, + resetsAt: window.resetsAt + ) + } + registry.accounts[index].lastRateLimitFetchAt = account.lastRateLimitFetchAt + registry.accounts[index].lastRateLimitError = account.lastRateLimitError + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func saveSharedAuth( + for account: CodexSavedAccountPayload, + codexHomeURL: URL + ) throws { + try saveSharedAuth( + from: codexHomeURL, + for: account, + codexHomeURL: codexHomeURL + ) + } + + static func commitAuthenticatedAccount( + _ authenticatedAccount: CodexSavedAccountPayload, + activation: LoginActivation, + authSourceCodexHomeURL: URL, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let sourceData = try validatedAuthData( + at: sharedAuthURL(codexHomeURL: authSourceCodexHomeURL) + ) + let existing = try loadRegistry(codexHomeURL: codexHomeURL) + let existingEntry = existing.accounts.first(where: { + normalizedAccountKey(from: $0) == authenticatedAccount.accountKey + }) + let sourceFingerprint = fingerprint(sourceData) + let revision: String + if let existingURL = existingEntry.flatMap({ entry in + immutableAuthURL( + for: entry, + accountKey: authenticatedAccount.accountKey, + codexHomeURL: codexHomeURL + ) + }), FileManager.default.fileExists(atPath: existingURL.path), + fingerprint(try validatedAuthData(at: existingURL)) == sourceFingerprint, + let immutableRevision = existingEntry?.immutableRevision { + revision = immutableRevision + } else { + revision = try writeImmutableRevision( + sourceData, + accountKey: authenticatedAccount.accountKey, + codexHomeURL: codexHomeURL + ) + } + var authenticatedAccount = authenticatedAccount + if let existingPayload = existingEntry.flatMap(makePayload(from:)) { + authenticatedAccount.rateLimits = existingPayload.rateLimits + authenticatedAccount.lastRateLimitFetchAt = existingPayload.lastRateLimitFetchAt + authenticatedAccount.lastRateLimitError = existingPayload.lastRateLimitError + } + var accounts = existing.accounts.compactMap(makePayload(from:)) + if let index = accounts.firstIndex(where: { $0.accountKey == authenticatedAccount.accountKey }) { + accounts[index] = authenticatedAccount + } else { + accounts.insert(authenticatedAccount, at: 0) + } + let normalizedActiveAccountKey: String? = switch activation { + case .activateAuthenticatedAccount: + authenticatedAccount.accountKey + case .preserveActiveAccount: + existing.activeAccountKey + } + var desired = existing + desired.activeAccountKey = normalizedActiveAccountKey + desired.accounts = mergedEntries( + accounts, + activeAccountKey: normalizedActiveAccountKey, + existing: existing.accounts + ) + guard let authenticatedIndex = desired.accounts.firstIndex(where: { + normalizedAccountKey(from: $0) == authenticatedAccount.accountKey + }) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Authenticated account \(authenticatedAccount.accountKey) is missing from its commit payload." + ) + } + desired.accounts[authenticatedIndex].immutableRevision = revision + let persistedDesired = try nextRegistry(from: desired) + try persistRegistry( + persistedDesired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func upsertAccount( + _ account: CodexSavedAccountPayload, + activation: LoginActivation, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let existing = try loadRegistry(codexHomeURL: codexHomeURL) + var account = account + if let existingEntry = existing.accounts.first(where: { + normalizedAccountKey(from: $0) == account.accountKey + }), let existingPayload = makePayload(from: existingEntry) { + account.rateLimits = existingPayload.rateLimits + account.lastRateLimitFetchAt = existingPayload.lastRateLimitFetchAt + account.lastRateLimitError = existingPayload.lastRateLimitError + } + var accounts = existing.accounts.compactMap(makePayload(from:)) + if let index = accounts.firstIndex(where: { $0.accountKey == account.accountKey }) { + accounts[index] = account + } else { + accounts.insert(account, at: 0) + } + let activeAccountKey: String? = switch activation { + case .activateAuthenticatedAccount: + account.accountKey + case .preserveActiveAccount: + existing.activeAccountKey + } + try saveRegistry( + .init( + schemaVersion: existing.schemaVersion, + generation: existing.generation, + contentHash: existing.contentHash, + activeAccountKey: activeAccountKey, + accounts: mergedEntries( + accounts, + activeAccountKey: activeAccountKey, + existing: existing.accounts + ) + ), + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func removeInactiveAccount( + accountKey: String, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + let existing = try loadRegistry(codexHomeURL: codexHomeURL) + precondition( + existing.activeAccountKey.map(CodexReviewAccount.normalizedEmail) != normalizedAccountKey, + "An active account removal requires the irreversible mutation journal." + ) + var desired = existing + desired.accounts.removeAll { + self.normalizedAccountKey(from: $0) == normalizedAccountKey + } + try saveRegistry( + desired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func reorderAccount( + accountKey: String, + toIndex: Int, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + var registry = try loadRegistry(codexHomeURL: codexHomeURL) + guard let sourceIndex = registry.accounts.firstIndex(where: { + self.normalizedAccountKey(from: $0) == normalizedAccountKey + }), registry.accounts.count > 1 else { + return + } + let destinationIndex = max(0, min(toIndex, registry.accounts.count - 1)) + guard sourceIndex != destinationIndex else { + return + } + let entry = registry.accounts.remove(at: sourceIndex) + registry.accounts.insert(entry, at: destinationIndex) + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func saveSharedAuth( + from sourceCodexHomeURL: URL, + for account: CodexSavedAccountPayload, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL) + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + return + } + let sourceData = try validatedAuthData(at: sourceURL) + let previousRegistry = try loadRegistry(codexHomeURL: codexHomeURL) + var desiredRegistry = previousRegistry + guard let index = desiredRegistry.accounts.firstIndex(where: { + normalizedAccountKey(from: $0) == account.accountKey + }) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Cannot attach authentication revision to missing account \(account.accountKey)." + ) + } + if let existingURL = immutableAuthURL( + for: desiredRegistry.accounts[index], + accountKey: account.accountKey, + codexHomeURL: codexHomeURL + ), FileManager.default.fileExists(atPath: existingURL.path) { + let existingData = try validatedAuthData(at: existingURL) + if fingerprint(existingData) == fingerprint(sourceData) { + return + } + } + let revision = try writeImmutableRevision( + sourceData, + accountKey: account.accountKey, + codexHomeURL: codexHomeURL + ) + desiredRegistry.accounts[index].immutableRevision = revision + let persistedDesired = try nextRegistry(from: desiredRegistry) + try persistRegistry( + persistedDesired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + static func removeSharedAuth(codexHomeURL: URL) throws { + let url = sharedAuthURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: url.path) else { + return + } + try FileManager.default.removeItem(at: url) + } + + static func removeSavedAccountDirectory( + accountKey: String, + codexHomeURL: URL + ) throws { + let directoryURL = savedAccountDirectoryURL(accountKey: accountKey, codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: directoryURL.path) else { + return + } + try FileManager.default.removeItem(at: directoryURL) + } + + static func copySavedAuth( + accountKey: String, + from sourceCodexHomeURL: URL, + to destinationCodexHomeURL: URL + ) throws -> Bool { + let targetAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + let registry = try loadRegistry(codexHomeURL: sourceCodexHomeURL) + guard let entry = registry.accounts.first(where: { + normalizedAccountKey(from: $0) == targetAccountKey + }), let sourceURL = immutableAuthURL( + for: entry, + accountKey: targetAccountKey, + codexHomeURL: sourceCodexHomeURL + ) else { + return false + } + _ = try validatedAuthData(at: sourceURL) + try copyAuth( + from: sourceURL, + to: sharedAuthURL(codexHomeURL: destinationCodexHomeURL) + ) + return true + } + + private static func makePayload(from entry: Entry) -> CodexSavedAccountPayload? { + let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) + guard email.isEmpty == false else { + return nil + } + let normalizedEmail = CodexReviewAccount.normalizedEmail(email) + let accountKey = entry.accountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { $0.isEmpty ? nil : $0 } + ?? normalizedEmail + return CodexSavedAccountPayload( + accountKey: accountKey, + email: email, + kind: entry.kind.accountKind, + planType: entry.planType, + capabilities: entry.kind.accountKind.capabilities, + rateLimits: entry.cachedRateLimits?.map(\.tuple) ?? [], + lastRateLimitFetchAt: entry.lastRateLimitFetchAt, + lastRateLimitError: entry.lastRateLimitError + ) + } + + private static func loadRegistry(codexHomeURL: URL) throws -> Registry { + if FileManager.default.fileExists(atPath: journalURL(codexHomeURL: codexHomeURL).path) { + let journal = try loadJournal(codexHomeURL: codexHomeURL) + try recoverJournal(journal, codexHomeURL: codexHomeURL) + } + return try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + } + + private static func loadRegistryWithoutRecovery(codexHomeURL: URL) throws -> Registry { + let url = registryURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: url.path) else { + return .init(activeAccountKey: nil, accounts: []) + } + do { + let data = try Data(contentsOf: url) + var registry = try JSONDecoder().decode(Registry.self, from: data) + guard registry.schemaVersion == 0 || registry.schemaVersion == Registry.currentSchemaVersion else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Unsupported account registry schema version \(registry.schemaVersion)." + ) + } + if registry.schemaVersion == Registry.currentSchemaVersion { + let expectedHash = try contentHash(for: registry) + guard registry.contentHash == expectedHash else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry content hash does not match its persisted content." + ) + } + } else { + registry = try migrateLegacyRegistry(registry, codexHomeURL: codexHomeURL) + try saveRegistry(registry, codexHomeURL: codexHomeURL) + registry = try JSONDecoder().decode( + Registry.self, + from: Data(contentsOf: url) + ) + } + try validateReferencedAuthRevisions(registry, codexHomeURL: codexHomeURL) + return registry + } catch let failure as CodexReviewAuthenticationFailure { + throw failure + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry is inconsistent: \(error.localizedDescription)" + ) + } + } + + private static func saveRegistry( + _ registry: Registry, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + try persistRegistry( + nextRegistry(from: registry), + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) + } + + private static func nextRegistry(from registry: Registry) throws -> Registry { + var registry = registry + registry.schemaVersion = Registry.currentSchemaVersion + registry.generation = registry.generation &+ 1 + registry.contentHash = try contentHash(for: registry) + return registry + } + + private static func persistRegistry( + _ registry: Registry, + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let url = registryURL(codexHomeURL: codexHomeURL) + guard registry.schemaVersion == Registry.currentSchemaVersion, + registry.contentHash == (try contentHash(for: registry)) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Refusing to persist an account registry with an invalid content hash." + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(registry) + do { + try writeAtomically( + data, + to: url, + permissions: 0o600, + destinationDidReplace: destinationDidReplace + ) + } catch let persistenceError { + let observed: Registry + do { + observed = try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry replacement outcome is unresolved. " + + "Write failure: \(persistenceError.localizedDescription). " + + "Reload failure: \(error.localizedDescription)" + ) + } + guard sameRegistry(observed, registry) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry replacement did not expose its desired durable state: " + + persistenceError.localizedDescription + ) + } + do { + try synchronizeFile(at: url) + try synchronizeDirectory(at: url.deletingLastPathComponent()) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The desired account registry is visible but its durability remains unresolved: " + + error.localizedDescription + ) + } + } + } + + private static func migrateLegacyRegistry( + _ legacy: Registry, + codexHomeURL: URL + ) throws -> Registry { + var migrated = legacy + migrated.schemaVersion = Registry.currentSchemaVersion + migrated.contentHash = "" + for index in migrated.accounts.indices { + guard migrated.accounts[index].immutableRevision == nil, + let accountKey = normalizedAccountKey(from: migrated.accounts[index]) + else { + continue + } + let legacyURL = savedAccountAuthURL( + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) + guard FileManager.default.fileExists(atPath: legacyURL.path) else { + continue + } + let data = try validatedAuthData(at: legacyURL) + migrated.accounts[index].immutableRevision = try writeImmutableRevision( + data, + accountKey: accountKey, + codexHomeURL: codexHomeURL, + preferredRevision: "legacy-0-\(fingerprint(data).prefix(16))" + ) + } + return migrated + } + + private static func validateReferencedAuthRevisions( + _ registry: Registry, + codexHomeURL: URL + ) throws { + for entry in registry.accounts { + guard entry.immutableRevision != nil else { + continue + } + guard let accountKey = normalizedAccountKey(from: entry), + let revisionURL = immutableAuthURL( + for: entry, + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "An account registry revision has no valid account identity." + ) + } + _ = try validatedAuthData(at: revisionURL) + } + } + + private static func garbageCollectOrphanedRevisions( + referencedBy registry: Registry, + codexHomeURL: URL + ) throws { + let referencedPaths = Set(registry.accounts.compactMap { entry -> String? in + guard let accountKey = normalizedAccountKey(from: entry), + let url = immutableAuthURL( + for: entry, + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) else { + return nil + } + return url.standardizedFileURL.path + }) + let accountsURL = accountsDirectoryURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: accountsURL.path) else { + return + } + let accountDirectories = try FileManager.default.contentsOfDirectory( + at: accountsURL, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) + for accountDirectory in accountDirectories { + let accountValues = try accountDirectory.resourceValues( + forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ) + guard accountValues.isDirectory == true, accountValues.isSymbolicLink != true else { + continue + } + let revisionsURL = accountDirectory.appendingPathComponent("revisions", isDirectory: true) + guard FileManager.default.fileExists(atPath: revisionsURL.path) else { + continue + } + let revisionValues = try revisionsURL.resourceValues( + forKeys: [.isDirectoryKey, .isSymbolicLinkKey] + ) + guard revisionValues.isDirectory == true, revisionValues.isSymbolicLink != true else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "An authentication revisions path is not a regular directory." + ) + } + let revisions = try FileManager.default.contentsOfDirectory( + at: revisionsURL, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) + var removedRevision = false + for revisionURL in revisions where revisionURL.pathExtension == "json" { + let values = try revisionURL.resourceValues( + forKeys: [.isRegularFileKey, .isSymbolicLinkKey] + ) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "An immutable authentication revision is not a regular file." + ) + } + guard referencedPaths.contains(revisionURL.standardizedFileURL.path) == false else { + continue + } + try FileManager.default.removeItem(at: revisionURL) + removedRevision = true + } + if removedRevision { + try synchronizeDirectory(at: revisionsURL) + } + let remainingRevisionURLs = try FileManager.default.contentsOfDirectory( + at: revisionsURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ).filter { $0.pathExtension == "json" } + let accountDirectoryPrefix = accountDirectory.standardizedFileURL.path + "/" + let isReferencedAccountDirectory = referencedPaths.contains { + $0.hasPrefix(accountDirectoryPrefix) + } + if remainingRevisionURLs.isEmpty, isReferencedAccountDirectory == false { + try FileManager.default.removeItem(at: accountDirectory) + try synchronizeDirectory(at: accountsURL) + } + } + } + + private struct RegistryContent: Encodable { + let schemaVersion: Int + let generation: UInt64 + let activeAccountKey: String? + let accounts: [Entry] + } + + private static func contentHash(for registry: Registry) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(RegistryContent( + schemaVersion: Registry.currentSchemaVersion, + generation: registry.generation, + activeAccountKey: registry.activeAccountKey, + accounts: registry.accounts + )) + return fingerprint(data) + } + + private static func writeJournal( + _ journal: MutationJournal, + codexHomeURL: URL + ) throws { + guard journal.beforeRegistry.contentHash == (try contentHash(for: journal.beforeRegistry)), + journal.desiredRegistry.contentHash == (try contentHash(for: journal.desiredRegistry)) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Refusing to persist an account mutation journal with invalid registry hashes." + ) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try writeAtomically( + encoder.encode(journal), + to: journalURL(codexHomeURL: codexHomeURL), + permissions: 0o600 + ) + } + + private static func loadJournal(codexHomeURL: URL) throws -> MutationJournal { + do { + return try JSONDecoder().decode( + MutationJournal.self, + from: Data(contentsOf: journalURL(codexHomeURL: codexHomeURL)) + ) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account mutation journal is inconsistent: \(error.localizedDescription)" + ) + } + } + + private static func removeJournal(codexHomeURL: URL) throws { + let url = journalURL(codexHomeURL: codexHomeURL) + try removeDurably(at: url) + } + + private static func recoverJournal( + _ journal: MutationJournal, + codexHomeURL: URL + ) throws { + guard journal.beforeRegistry.contentHash == (try contentHash(for: journal.beforeRegistry)), + journal.desiredRegistry.contentHash == (try contentHash(for: journal.desiredRegistry)) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account mutation journal contains invalid registry hashes." + ) + } + let currentRegistry = try loadRegistryWithoutRecovery(codexHomeURL: codexHomeURL) + let currentSharedFingerprint = try sharedAuthFingerprint(codexHomeURL: codexHomeURL) + let registryIsBefore = sameRegistry(currentRegistry, journal.beforeRegistry) + let registryIsDesired = sameRegistry(currentRegistry, journal.desiredRegistry) + guard registryIsBefore || registryIsDesired else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry matches neither side of its durable mutation journal." + ) + } + let sharedIsBefore = currentSharedFingerprint == journal.beforeSharedAuthFingerprint + let sharedIsDesired = currentSharedFingerprint == journal.desiredSharedAuthFingerprint + guard sharedIsBefore || sharedIsDesired else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Shared authentication matches neither side of its durable mutation journal." + ) + } + let irreversibleEffectMayHaveApplied = journal.mayApplyIrreversibleLogout + && (currentSharedFingerprint == nil || sharedIsBefore == false) + let shouldForward = registryIsDesired || sharedIsDesired || irreversibleEffectMayHaveApplied + guard shouldForward else { + try removeJournal(codexHomeURL: codexHomeURL) + return + } + if sharedIsDesired == false { + try applySharedAuthAction(journal, codexHomeURL: codexHomeURL) + } + if registryIsDesired == false { + try persistRegistry(journal.desiredRegistry, codexHomeURL: codexHomeURL) + } + try removeJournal(codexHomeURL: codexHomeURL) + } + + private static func applySharedAuthAction( + _ journal: MutationJournal, + codexHomeURL: URL + ) throws { + switch journal.sharedAuthAction { + case .remove: + try removeSharedAuth(codexHomeURL: codexHomeURL) + try synchronizeDirectory(at: codexHomeURL) + case .replace: + guard let accountKey = journal.replacementAccountKey, + let revision = journal.replacementRevision else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "A replacement journal is missing its immutable revision reference." + ) + } + try copyAuth( + from: immutableAuthURL( + accountKey: accountKey, + revision: revision, + codexHomeURL: codexHomeURL + ), + to: sharedAuthURL(codexHomeURL: codexHomeURL) + ) + } + let actualFingerprint = try sharedAuthFingerprint(codexHomeURL: codexHomeURL) + guard actualFingerprint == journal.desiredSharedAuthFingerprint else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Shared authentication did not reach the journaled desired fingerprint." + ) + } + } + + private static func sharedAuthFingerprint(codexHomeURL: URL) throws -> String? { + let url = sharedAuthURL(codexHomeURL: codexHomeURL) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + return fingerprint(try validatedAuthData(at: url)) + } + + private static func sameRegistry(_ lhs: Registry, _ rhs: Registry) -> Bool { + lhs.generation == rhs.generation && lhs.contentHash == rhs.contentHash + } + + private static func copyAuth(from sourceURL: URL, to destinationURL: URL) throws { + let sourceData = try validatedAuthData(at: sourceURL) + try writeAtomically( + sourceData, + to: destinationURL, + permissions: 0o600 + ) + let destinationData = try validatedAuthData(at: destinationURL) + guard fingerprint(destinationData) == fingerprint(sourceData) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Authentication copy fingerprint mismatch." + ) + } + } + + private static func writeImmutableRevision( + _ data: Data, + accountKey: String, + codexHomeURL: URL, + preferredRevision: String? = nil + ) throws -> String { + _ = try validatedAuthObject(data) + let revision = preferredRevision ?? UUID().uuidString.lowercased() + let url = immutableAuthURL( + accountKey: accountKey, + revision: revision, + codexHomeURL: codexHomeURL + ) + let directoryURL = url.deletingLastPathComponent() + try createDirectoryHierarchy( + at: directoryURL + ) + if FileManager.default.fileExists(atPath: url.path) { + let existing = try validatedAuthData(at: url) + guard fingerprint(existing) == fingerprint(data) else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Immutable authentication revision \(revision) has conflicting content." + ) + } + try synchronizeFile(at: url) + try synchronizeDirectory(at: directoryURL) + return revision + } + guard FileManager.default.createFile( + atPath: url.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Could not create immutable authentication revision \(revision)." + ) + } + do { + let handle = try FileHandle(forWritingTo: url) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + try synchronizeDirectory(at: directoryURL) + let persisted = try validatedAuthData(at: url) + guard fingerprint(persisted) == fingerprint(data) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Immutable authentication revision fingerprint mismatch." + ) + } + return revision + } catch { + let originalError = error + do { + try removeDurably(at: url) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Immutable authentication revision creation failed and its partial file could not be durably removed. " + + "Original failure: \(originalError.localizedDescription). " + + "Cleanup failure: \(error.localizedDescription)" + ) + } + throw originalError + } + } + + private static func validatedAuthData(at url: URL) throws -> Data { + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, (values.fileSize ?? 0) > 0 else { + throw CodexReviewAuthenticationFailure.nonExportableCredentialStore + } + let data = try Data(contentsOf: url) + _ = try validatedAuthObject(data) + return data + } + + private static func validatedAuthObject(_ data: Data) throws -> [String: Any] { + guard data.isEmpty == false, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw CodexReviewAuthenticationFailure.nonExportableCredentialStore + } + return object + } + + private static func fingerprint(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func writeAtomically( + _ data: Data, + to destinationURL: URL, + permissions: Int, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil + ) throws { + let directoryURL = destinationURL.deletingLastPathComponent() + try createDirectoryHierarchy( + at: directoryURL, + directoryDurabilityDidSynchronize: directoryDurabilityDidSynchronize + ) + let replacementURL = directoryURL.appendingPathComponent( + ".\(destinationURL.lastPathComponent).replacement-\(UUID().uuidString)" + ) + guard FileManager.default.createFile( + atPath: replacementURL.path, + contents: nil, + attributes: [.posixPermissions: permissions] + ) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Could not create registry replacement file." + ) + } + var didReplaceDestination = false + do { + let handle = try FileHandle(forWritingTo: replacementURL) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + try renameAtomically(from: replacementURL, to: destinationURL) + didReplaceDestination = true + try destinationDidReplace?() + try synchronizeFile(at: destinationURL) + try synchronizeDirectory(at: directoryURL) + } catch { + if (try? Data(contentsOf: destinationURL)) == data { + do { + try synchronizeFile(at: destinationURL) + try synchronizeDirectory(at: directoryURL) + if FileManager.default.fileExists(atPath: replacementURL.path) { + try removeDurably(at: replacementURL) + } + return + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The replacement at \(destinationURL.path) is visible but its durability remains unresolved: " + + error.localizedDescription + ) + } + } + if didReplaceDestination { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The replacement outcome at \(destinationURL.path) is unresolved: " + + error.localizedDescription + ) + } + if FileManager.default.fileExists(atPath: replacementURL.path) { + do { + try removeDurably(at: replacementURL) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Atomic replacement failed before commit and its temporary file could not be removed: " + + error.localizedDescription + ) + } + } + throw error + } + } + + private static func createDirectoryHierarchy( + at directoryURL: URL, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil + ) throws { + let directoryURL = directoryURL.standardizedFileURL + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + var cursor = directoryURL + while true { + try synchronizeDirectory(at: cursor) + try directoryDurabilityDidSynchronize?(cursor) + guard cursor != filesystemRootURL else { + return + } + let parent = cursor.deletingLastPathComponent() + guard parent != cursor else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "Directory durability escaped its owning ancestor at \(directoryURL.path)." + ) + } + cursor = parent + } + } + + private static func renameAtomically(from sourceURL: URL, to destinationURL: URL) throws { + let result = sourceURL.path.withCString { sourcePath in + destinationURL.path.withCString { destinationPath in + Darwin.rename(sourcePath, destinationPath) + } + } + guard result == 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + } + + private static func synchronizeFile(at url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + try handle.synchronize() + try handle.close() + } + + private static func removeDurably( + at url: URL, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil + ) throws { + let directoryURL = url.deletingLastPathComponent() + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + guard FileManager.default.fileExists(atPath: url.path) == false else { + throw error + } + } + } + guard FileManager.default.fileExists(atPath: url.path) == false else { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The durable removal left its destination visible at \(url.path)." + ) + } + guard FileManager.default.fileExists(atPath: directoryURL.path) else { + return + } + do { + try synchronizeDirectory(at: directoryURL) + try directoryDurabilityDidSynchronize?(directoryURL) + } catch { + do { + try synchronizeDirectory(at: directoryURL) + try directoryDurabilityDidSynchronize?(directoryURL) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The removal at \(url.path) is visible but its directory durability remains unresolved: " + + error.localizedDescription + ) + } + } + } + + private static func synchronizeDirectory(at url: URL) throws { + let descriptor = open(url.path, O_RDONLY) + guard descriptor >= 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + defer { Darwin.close(descriptor) } + guard fsync(descriptor) == 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + } + + private static func normalizedAccountKey(from entry: Entry) -> String? { + let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedEmail = CodexReviewAccount.normalizedEmail(email) + return entry.accountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { $0.isEmpty ? nil : $0 } + ?? (normalizedEmail.isEmpty ? nil : normalizedEmail) + } + + private static func registryURL(codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent("registry.json") + } + + private static func journalURL(codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent("mutation-journal.json") + } + + private static func reconciliationDebtURL(codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent("reconciliation-debt.json") + } + + private static func temporaryHomeCleanupDebtURL(codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent("temporary-home-cleanup-debt.json") + } + + private static func sharedAuthURL(codexHomeURL: URL) -> URL { + codexHomeURL.appendingPathComponent("auth.json") + } + + private static func savedAccountAuthURL(accountKey: String, codexHomeURL: URL) -> URL { + savedAccountDirectoryURL(accountKey: accountKey, codexHomeURL: codexHomeURL) + .appendingPathComponent("auth.json") + } + + private static func immutableAuthURL( + for entry: Entry, + accountKey: String, + codexHomeURL: URL + ) -> URL? { + guard let revision = entry.immutableRevision else { + return nil + } + return immutableAuthURL( + accountKey: accountKey, + revision: revision, + codexHomeURL: codexHomeURL + ) + } + + private static func immutableAuthURL( + accountKey: String, + revision: String, + codexHomeURL: URL + ) -> URL { + savedAccountDirectoryURL(accountKey: accountKey, codexHomeURL: codexHomeURL) + .appendingPathComponent("revisions", isDirectory: true) + .appendingPathComponent("\(revision).json") + } + + private static func savedAccountDirectoryURL(accountKey: String, codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) + } + + private static func accountsDirectoryURL(codexHomeURL: URL) -> URL { + codexHomeURL.appendingPathComponent("accounts", isDirectory: true) + } + + private static func pathComponent(forAccountKey accountKey: String) -> String { + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + switch normalizedAccountKey { + case ".": + return "%2E" + case "..": + return "%2E%2E" + default: + break + } + return normalizedAccountKey + .addingPercentEncoding(withAllowedCharacters: accountDirectoryNameAllowedCharacters) + ?? normalizedAccountKey + } + + private static let accountDirectoryNameAllowedCharacters = + CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + } +} diff --git a/Sources/CodexReviewHost/AccountRegistryStore.swift b/Sources/CodexReviewHost/AccountRegistryStore.swift new file mode 100644 index 00000000..5933065b --- /dev/null +++ b/Sources/CodexReviewHost/AccountRegistryStore.swift @@ -0,0 +1,462 @@ +import Foundation +import OSLog +import CodexReviewKit + +private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") + +struct IsolatedLoginProductCommitCancelled: Error, Sendable {} + +struct IsolatedLoginProductCommitFailure: Error, LocalizedError, Sendable { + let failure: CodexReviewAuthenticationFailure + + var errorDescription: String? { + failure.localizedDescription + } +} + +actor AccountRegistryStore { + struct Snapshot: Sendable { + let accounts: [CodexSavedAccountPayload] + let activeAccountKey: String? + } + + struct MutationLease: Hashable, Sendable { + let id: UUID + } + + struct AccountMutation: Sendable { + let lease: MutationLease + let before: Snapshot + } + + struct PreparedMutation: Hashable, Sendable { + let id: UUID + } + + enum PreparedAbortDisposition: Sendable { + case restoredBefore(Snapshot) + case forwardedDesired(Snapshot) + } + + struct RuntimeCommitAuthorization: Sendable { + let generation: UInt64 + + init(generation: UInt64) { + self.generation = generation + } + } + + struct AuthenticationMutation: Sendable { + let lease: MutationLease + let purpose: LoginPurpose + let previousActiveAccountKey: String? + } + + enum TemporaryCodexHomeKind: Sendable { + case authentication + case rateLimits + + var pathPrefix: String { + switch self { + case .authentication: + "codex-review-auth-" + case .rateLimits: + "codex-review-rate-limits-" + } + } + } + + private enum MutationKind: Equatable { + case authentication + case account + } + + let codexHomeURL: URL + private let authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? + private let authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? + private let authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? + private let registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? + private let directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? + private let loadDidBegin: CodexReviewAccountRegistryLoadDidBegin? + private var activeMutation: ( + lease: MutationLease, + kind: MutationKind, + cancellationRequested: Bool, + productCommitClaimed: Bool + )? + private var activeRuntimeGeneration: UInt64? + + init( + codexHomeURL: URL, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + directoryDurabilityDidSynchronize: (@Sendable (URL) throws -> Void)? = nil, + loadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil + ) { + self.codexHomeURL = codexHomeURL + self.authenticationMutationDidBegin = authenticationMutationDidBegin + self.authenticationCancellationDidRequest = authenticationCancellationDidRequest + self.authenticationProductCommitDidApply = authenticationProductCommitDidApply + self.registryDestinationDidReplace = registryDestinationDidReplace + self.directoryDurabilityDidSynchronize = directoryDurabilityDidSynchronize + self.loadDidBegin = loadDidBegin + } + + nonisolated static func loadInitialSnapshot(codexHomeURL: URL) throws -> Snapshot { + try Disk.load(codexHomeURL: codexHomeURL) + } + + func load() async throws -> Snapshot { + await loadDidBegin?() + return try Disk.load(codexHomeURL: codexHomeURL) + } + + func openRuntimeAdmission(generation: UInt64) { + guard activeRuntimeGeneration == nil || activeRuntimeGeneration == generation else { + preconditionFailure("A new runtime generation cannot publish before the previous registry admission closes.") + } + activeRuntimeGeneration = generation + } + + func closeRuntimeAdmission(generation: UInt64) { + guard activeRuntimeGeneration == generation else { + return + } + activeRuntimeGeneration = nil + } + + func deactivateAccount( + authorization: MutationLease?, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) async throws -> Snapshot { + try requireMutationAuthorization(authorization) + try requireRuntimeAuthorization(runtimeAuthorization) + try Disk.deactivateAccount( + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } + + func prepareAccountActivation(_ accountKey: String) throws -> PreparedMutation { + try Disk.prepareAccountActivation(accountKey, codexHomeURL: codexHomeURL) + } + + func updateCachedRateLimits( + from account: CodexSavedAccountPayload, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) async throws { + try requireNoAccountMutationForBackgroundPersistence() + try requireRuntimeAuthorization(runtimeAuthorization) + try Disk.updateCachedRateLimits( + from: account, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + } + + func saveSharedAuth( + from sourceCodexHomeURL: URL? = nil, + for account: CodexSavedAccountPayload, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) throws { + try requireNoAccountMutationForBackgroundPersistence() + try requireRuntimeAuthorization(runtimeAuthorization) + try Disk.saveSharedAuth( + from: sourceCodexHomeURL ?? codexHomeURL, + for: account, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + } + + func commitAuthenticatedAccount( + _ authenticatedAccount: CodexSavedAccountPayload, + activation: LoginActivation, + authSourceCodexHomeURL: URL?, + authorization: MutationLease?, + runtimeAuthorization: RuntimeCommitAuthorization? = nil, + isolatedProductCommitAuthorization: MutationLease? = nil + ) async throws -> Snapshot { + try requireMutationAuthorization(authorization) + try requireRuntimeAuthorization(runtimeAuthorization) + let didClaimIsolatedProductCommit: Bool + if let isolatedProductCommitAuthorization, + claimAuthenticationProductCommit(isolatedProductCommitAuthorization) == false { + throw IsolatedLoginProductCommitCancelled() + } else { + didClaimIsolatedProductCommit = isolatedProductCommitAuthorization != nil + } + do { + try Disk.commitAuthenticatedAccount( + authenticatedAccount, + activation: activation, + authSourceCodexHomeURL: authSourceCodexHomeURL ?? codexHomeURL, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + if didClaimIsolatedProductCommit { + await authenticationProductCommitDidApply?() + } + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } catch { + guard didClaimIsolatedProductCommit else { + throw error + } + let failure = (error as? CodexReviewAuthenticationFailure) + ?? CodexReviewAuthenticationFailure.accountCommit(message: error.localizedDescription) + throw IsolatedLoginProductCommitFailure(failure: failure) + } + } + + func upsertAccount( + _ account: CodexSavedAccountPayload, + activation: LoginActivation, + authorization: MutationLease?, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) async throws -> Snapshot { + try requireMutationAuthorization(authorization) + try requireRuntimeAuthorization(runtimeAuthorization) + try Disk.upsertAccount( + account, + activation: activation, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } + + func prepareIrreversibleRemoval( + accountKey: String + ) throws -> PreparedMutation { + try Disk.prepareIrreversibleRemoval( + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) + } + + func commitPreparedMutation(_ mutation: PreparedMutation) throws -> Snapshot { + try Disk.commitPreparedMutation(mutation, codexHomeURL: codexHomeURL) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } + + func abortPreparedMutation( + _ mutation: PreparedMutation + ) throws -> PreparedAbortDisposition { + try Disk.abortPreparedMutation(mutation, codexHomeURL: codexHomeURL) + } + + func removeInactiveAccount(accountKey: String) throws -> Snapshot { + try Disk.removeInactiveAccount( + accountKey: accountKey, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } + + func reorderAccount(accountKey: String, toIndex: Int) throws -> Snapshot { + try Disk.reorderAccount( + accountKey: accountKey, + toIndex: toIndex, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + } + + func cleanupRemovedAccountDirectory(accountKey: String) { + do { + try Disk.removeSavedAccountDirectory( + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) + } catch { + // The registry replace is the product commit. A stale account directory + // is unreferenced data and is collected by the next load; it cannot roll + // a committed account selection back into the UI. + logger.error( + "Committed account removal left cleanup debt for \(accountKey, privacy: .private(mask: .hash)): \(error.localizedDescription, privacy: .public)" + ) + } + } + + func recordReconciliationDebt( + expectedAccount: ExpectedRuntimeAccount, + message: String + ) throws { + try Disk.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message, + codexHomeURL: codexHomeURL, + directoryDurabilityDidSynchronize: directoryDurabilityDidSynchronize + ) + } + + func reconciliationDebtExpectation() throws -> ExpectedRuntimeAccount? { + try Disk.reconciliationDebtExpectation(codexHomeURL: codexHomeURL) + } + + func clearReconciliationDebt() throws { + try Disk.clearReconciliationDebt(codexHomeURL: codexHomeURL) + } + + func reserveTemporaryCodexHome(kind: TemporaryCodexHomeKind) throws -> URL { + try Disk.reserveTemporaryCodexHome(kind: kind, codexHomeURL: codexHomeURL) + } + + func finishTemporaryCodexHome(_ url: URL) { + do { + try Disk.finishTemporaryCodexHome( + url, + codexHomeURL: codexHomeURL, + directoryDurabilityDidSynchronize: directoryDurabilityDidSynchronize + ) + } catch { + preconditionFailure( + "Credential-bearing temporary home cleanup debt must be durable: \(error.localizedDescription)" + ) + } + } + + func copySavedAuth(accountKey: String, to destinationCodexHomeURL: URL) throws -> Bool { + try requireNoAccountMutationForBackgroundPersistence() + return try Disk.copySavedAuth( + accountKey: accountKey, + from: codexHomeURL, + to: destinationCodexHomeURL + ) + } + + func beginAuthenticationMutation(request: LoginRequest) async throws -> AuthenticationMutation { + if let activeMutation { + switch activeMutation.kind { + case .authentication: + throw CodexReviewAuthenticationFailure.alreadyInProgress + case .account: + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + } + let snapshot = try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + let purpose: LoginPurpose = switch request { + case .signIn: + .signIn + case .addAccount: + snapshot.activeAccountKey == nil ? .signIn : .addAccountPreservingActive + } + let mutation = AuthenticationMutation( + lease: installMutation(kind: .authentication), + purpose: purpose, + previousActiveAccountKey: snapshot.activeAccountKey + ) + await authenticationMutationDidBegin?() + return mutation + } + + func beginAccountMutation() async throws -> AccountMutation { + guard activeMutation == nil else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + let lease = installMutation(kind: .account) + await loadDidBegin?() + do { + let before = try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) + return .init(lease: lease, before: before) + } catch { + precondition(activeMutation?.lease == lease) + activeMutation = nil + throw error + } + } + + func finishMutation(_ lease: MutationLease) { + precondition(activeMutation?.lease == lease, "Only the active account mutation owner can release its lease.") + activeMutation = nil + } + + func requestAuthenticationCancellation(_ lease: MutationLease) async { + guard activeMutation?.lease == lease, + activeMutation?.kind == .authentication else { + return + } + if activeMutation?.productCommitClaimed == false { + activeMutation?.cancellationRequested = true + } + await authenticationCancellationDidRequest?() + } + + private func installMutation(kind: MutationKind) -> MutationLease { + let lease = MutationLease(id: UUID()) + activeMutation = ( + lease: lease, + kind: kind, + cancellationRequested: false, + productCommitClaimed: false + ) + return lease + } + + private func claimAuthenticationProductCommit(_ lease: MutationLease) -> Bool { + guard activeMutation?.lease == lease, + activeMutation?.kind == .authentication else { + preconditionFailure("Only the active authentication lease can claim its product commit.") + } + guard activeMutation?.cancellationRequested == false else { + return false + } + activeMutation?.productCommitClaimed = true + return true + } + + private func requireNoAccountMutationForBackgroundPersistence() throws { + guard activeMutation == nil else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Background account metadata persistence is blocked while an account mutation or authentication is in progress." + ) + } + } + + private func requireMutationAuthorization( + _ authorization: MutationLease? + ) throws { + guard let activeMutation else { + guard authorization == nil else { + preconditionFailure("A released account mutation lease cannot authorize registry work.") + } + return + } + guard authorization == activeMutation.lease else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + } + + private func requireRuntimeAuthorization( + _ authorization: RuntimeCommitAuthorization? + ) throws { + guard let authorization else { + return + } + guard activeRuntimeGeneration == authorization.generation else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + } +} + +extension AccountRegistryStore.Snapshot { + var expectedRuntimeAccount: ExpectedRuntimeAccount { + guard let activeAccountKey else { + return .signedOut + } + guard let account = accounts.first(where: { + $0.accountKey == activeAccountKey + }) else { + preconditionFailure("An active account registry snapshot requires its account payload.") + } + return .observedAccount( + accountKey: activeAccountKey, + provider: .init(account.kind) + ) + } +} diff --git a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift new file mode 100644 index 00000000..afa0c1e1 --- /dev/null +++ b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift @@ -0,0 +1,843 @@ +import Foundation +import CodexReviewKit + +enum ExpectedRuntimeAccount: Equatable, Sendable { + enum Provider: String, Codable, Equatable, Sendable { + case chatGPT + case apiKey + case amazonBedrock + + init(_ kind: CodexReviewBackendModel.Account.Kind) { + switch kind { + case .chatGPT: + self = .chatGPT + case .apiKey: + self = .apiKey + case .amazonBedrock: + self = .amazonBedrock + } + } + + var accountKind: CodexReviewBackendModel.Account.Kind { + switch self { + case .chatGPT: + .chatGPT + case .apiKey: + .apiKey + case .amazonBedrock: + .amazonBedrock + } + } + } + + case signedOut + case account(String) + case observedAccount(accountKey: String, provider: Provider) + case anyChatGPT + case cancelOutcomeUnknown(previousActiveAccountKey: String?) + case reconcileCurrentRuntime +} + +@MainActor +final class AccountRuntimeTransitionCoordinator { + @MainActor + private final class FinalShutdownCompletion { + private var result: Bool? + private var waiters: [CheckedContinuation] = [] + + func wait() async -> Bool { + if let result { return result } + return await withCheckedContinuation { waiters.append($0) } + } + + func resolve(_ result: Bool) { + precondition(self.result == nil) + self.result = result + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { waiter.resume(returning: result) } + } + } + + @MainActor + private final class PrimaryReconciliationCompletion { + private var isResolved = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + if isResolved { return } + await withCheckedContinuation { waiters.append($0) } + } + + func resolve() { + precondition(isResolved == false) + isResolved = true + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { waiter.resume() } + } + } + + enum AccountMutationPhase: Equatable, Sendable { + case preEffect + case effectClaimed + case effectApplied + case registryCommitted + case replacementValidating + } + + enum EffectClaim: Sendable { + case apply + case abortForFinalShutdown + } + + enum PublicationClaim: Equatable, Sendable { + case published + case quiescent + } + + enum PrimaryReconciliationAdmission: Sendable { + case accepted + case deferUntilRuntimeStop + } + + enum PrimaryLoginReconciliationHandoff: Sendable { + case handedOff + case deferUntilRuntimeStop + } + + struct AccountTransition: Sendable { + fileprivate let id: UUID + } + + struct PrimaryReconciliationReservation: Sendable { + fileprivate let id: UUID + } + + struct ExplicitRuntimeStart: Sendable { + fileprivate let id: UUID + } + + struct RuntimeAuthReconciliation: Sendable { + fileprivate let id: UUID + fileprivate let generation: UInt64 + } + + struct LoginAdmission: Sendable { + fileprivate let id: UUID + } + + private enum ActiveTransition { + enum RuntimeAuthPhase { + case reading + case registryEffectClaimed + } + + case account(id: UUID, phase: AccountMutationPhase) + case primaryReconciliation(id: UUID, completion: PrimaryReconciliationCompletion) + case explicitRuntimeStart(id: UUID, repairsReconciliation: Bool, commitClaimed: Bool) + case runtimeAuthReconciliation( + id: UUID, + generation: UInt64, + phase: RuntimeAuthPhase + ) + case loginAdmission(id: UUID) + case primaryLogin(id: UUID) + + var id: UUID { + switch self { + case .account(let id, _), + .primaryReconciliation(let id, _), + .explicitRuntimeStart(let id, _, _), + .runtimeAuthReconciliation(let id, _, _), + .loginAdmission(let id), + .primaryLogin(let id): + return id + } + } + } + + private var activeTransition: ActiveTransition? + private var transitionCompletionWaiters: [CheckedContinuation] = [] + private var finalShutdownRequested = false + private var finalShutdownCompleted = false + private var finalShutdownCompletion: FinalShutdownCompletion? + private var reconciliationFailed = false + private var supersededExplicitRuntimeStarts: Set = [] + private var supersededRuntimeAuthReconciliations: Set = [] + private var supersededPrimaryLogins: Set = [] + private struct PendingPrimaryReconciliation { + let reservation: PrimaryReconciliationReservation + let completion: PrimaryReconciliationCompletion + let operation: @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + } + + private var pendingPrimaryReconciliation: PendingPrimaryReconciliation? + private let finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? + private var didBecomeIdle: (@MainActor @Sendable () -> Void)? + + init(finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil) { + self.finalShutdownDidRequest = finalShutdownDidRequest + } + + func installDidBecomeIdle( + _ operation: @escaping @MainActor @Sendable () -> Void + ) { + precondition(didBecomeIdle == nil) + didBecomeIdle = operation + } + + func perform( + _ operation: (AccountTransition) async throws -> T + ) async throws -> T { + guard activeTransition == nil, + finalShutdownRequested == false, + finalShutdownCompleted == false, + reconciliationFailed == false else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + let id = UUID() + activeTransition = .account(id: id, phase: .preEffect) + defer { finishTransition(id: id) } + return try await operation(.init(id: id)) + } + + func reserveLoginAdmission() throws -> LoginAdmission { + guard activeTransition == nil, + canPublish else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + let id = UUID() + activeTransition = .loginAdmission(id: id) + return .init(id: id) + } + + func canCommitLoginAdmission(_ admission: LoginAdmission) -> Bool { + guard case .loginAdmission(let id) = activeTransition, + id == admission.id else { + return false + } + return canPublish + } + + func finishLoginAdmission(_ admission: LoginAdmission) { + guard case .loginAdmission(let id) = activeTransition, + id == admission.id else { + preconditionFailure("Only the active login admission can finish.") + } + finishTransition(id: id) + } + + func retainPrimaryLoginAdmission(_ admission: LoginAdmission) { + guard case .loginAdmission(let id) = activeTransition, + id == admission.id else { + preconditionFailure("Only the active login admission can retain primary runtime ownership.") + } + activeTransition = .primaryLogin(id: id) + } + + func claimPrimaryLoginResultPublication( + _ admission: LoginAdmission + ) -> Bool { + guard case .primaryLogin(let id) = activeTransition, + id == admission.id else { + return false + } + return canPublish + } + + func finishPrimaryLoginAdmission(_ admission: LoginAdmission) { + guard case .primaryLogin(let id) = activeTransition, + id == admission.id else { + if supersededPrimaryLogins.remove(admission.id) != nil { + return + } + preconditionFailure("Only the active primary login can release runtime ownership.") + } + finishTransition(id: id) + } + + func commitPrimaryLoginReconciliationFailure( + _ admission: LoginAdmission + ) -> Bool { + guard case .primaryLogin(let id) = activeTransition, + id == admission.id else { + if supersededPrimaryLogins.contains(admission.id) { + return false + } + preconditionFailure("Only the active primary login can fail reconciliation closed.") + } + return commitReconciliationFailureIfPublishable() + } + + func handoffPrimaryLoginToReconciliation( + _ admission: LoginAdmission, + operation: @escaping @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + ) -> PrimaryLoginReconciliationHandoff { + guard case .primaryLogin(let id) = activeTransition, + id == admission.id else { + if supersededPrimaryLogins.contains(admission.id) + || finalShutdownRequested + || finalShutdownCompleted { + return .deferUntilRuntimeStop + } + preconditionFailure("Only the active primary login can hand off reconciliation ownership.") + } + guard canPublish else { + return .deferUntilRuntimeStop + } + activeTransition = nil + startPrimaryReconciliation(operation) + return .handedOff + } + + func claimEffect(_ transition: AccountTransition) -> EffectClaim { + guard case .account(let id, .preEffect) = activeTransition, + id == transition.id else { + preconditionFailure("An account transition can claim its external effect only once.") + } + guard finalShutdownRequested == false else { + return .abortForFinalShutdown + } + activeTransition = .account(id: id, phase: .effectClaimed) + return .apply + } + + func recordEffectApplied(_ transition: AccountTransition) { + guard case .account(let id, .effectClaimed) = activeTransition, + id == transition.id else { + preconditionFailure("Only a claimed account effect can be recorded as applied.") + } + activeTransition = .account(id: id, phase: .effectApplied) + } + + func recordEffectAborted(_ transition: AccountTransition) { + guard case .account(let id, .effectClaimed) = activeTransition, + id == transition.id else { + preconditionFailure("Only a claimed account effect can be restored before application.") + } + activeTransition = .account(id: id, phase: .preEffect) + } + + func recordRegistryCommit(_ transition: AccountTransition) { + guard case .account(let id, let phase) = activeTransition, + id == transition.id, + phase == .effectClaimed || phase == .effectApplied else { + preconditionFailure("A registry commit requires a claimed or applied account effect.") + } + activeTransition = .account(id: id, phase: .registryCommitted) + } + + func claimPublication(_ transition: AccountTransition) -> PublicationClaim { + guard case .account(let id, .registryCommitted) = activeTransition, + id == transition.id else { + preconditionFailure("Only a committed account transition can claim replacement publication.") + } + activeTransition = .account(id: id, phase: .replacementValidating) + return publicationClaim + } + + func claimPreEffectRecovery(_ transition: AccountTransition) -> PublicationClaim { + guard case .account(let id, .preEffect) = activeTransition, + id == transition.id else { + preconditionFailure("Only a pre-effect account transition can recover its previous runtime.") + } + return publicationClaim + } + + func commitAccountReconciliationFailure(_ transition: AccountTransition) -> Bool { + guard case .account(let id, _) = activeTransition, + id == transition.id else { + preconditionFailure("Only the active account transition can record reconciliation failure.") + } + return commitReconciliationFailureIfPublishable() + } + + func commitPrimaryReconciliationFailure( + _ reservation: PrimaryReconciliationReservation + ) -> Bool { + guard case .primaryReconciliation(let id, _) = activeTransition, + id == reservation.id else { + preconditionFailure("Only the active primary reconciliation can record reconciliation failure.") + } + return commitReconciliationFailureIfPublishable() + } + + func commitUnownedReconciliationFailure() -> Bool { + guard activeTransition == nil else { + return false + } + return commitReconciliationFailureIfPublishable() + } + + func shouldStageRuntimePublication(_ transition: AccountTransition) -> Bool { + guard case .account(let id, let phase) = activeTransition, + id == transition.id, + phase == .preEffect || phase == .replacementValidating else { + return false + } + return canPublish + } + + func claimRuntimePublication(_ transition: AccountTransition) -> Bool { + shouldStageRuntimePublication(transition) + } + + func primaryPublicationClaim( + _ reservation: PrimaryReconciliationReservation + ) -> PublicationClaim { + guard case .primaryReconciliation(let id, _) = activeTransition, + id == reservation.id else { + preconditionFailure("Only the active primary reconciliation can choose runtime publication.") + } + return publicationClaim + } + + func shouldStageRuntimePublication( + _ reservation: PrimaryReconciliationReservation + ) -> Bool { + guard case .primaryReconciliation(let id, _) = activeTransition, + id == reservation.id else { + return false + } + return canPublish + } + + func claimRuntimePublication( + _ reservation: PrimaryReconciliationReservation + ) -> Bool { + shouldStageRuntimePublication(reservation) + } + + func commitLoginResultPublication() -> Bool { + guard activeTransition == nil else { + return false + } + return canPublish + } + + func reserveRuntimeAuthReconciliation( + generation: UInt64 + ) -> RuntimeAuthReconciliation? { + guard activeTransition == nil, canPublish else { + return nil + } + let id = UUID() + activeTransition = .runtimeAuthReconciliation( + id: id, + generation: generation, + phase: .reading + ) + return .init(id: id, generation: generation) + } + + func claimRuntimeAuthRegistryEffect( + _ reconciliation: RuntimeAuthReconciliation + ) -> Bool { + guard case .runtimeAuthReconciliation( + let id, + let generation, + .reading + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + return false + } + guard canPublish else { + return false + } + activeTransition = .runtimeAuthReconciliation( + id: id, + generation: generation, + phase: .registryEffectClaimed + ) + return true + } + + func canPublishRuntimeAuthReadResult( + _ reconciliation: RuntimeAuthReconciliation + ) -> Bool { + guard case .runtimeAuthReconciliation( + let id, + let generation, + .reading + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + return false + } + return canPublish + } + + func continueRuntimeAuthReadingAfterRegistryCommit( + _ reconciliation: RuntimeAuthReconciliation + ) -> Bool { + guard case .runtimeAuthReconciliation( + let id, + let generation, + .registryEffectClaimed + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + preconditionFailure("Only a committed runtime authentication effect can continue reading.") + } + guard canPublish else { + supersededRuntimeAuthReconciliations.insert(id) + finishTransition(id: id) + return false + } + activeTransition = .runtimeAuthReconciliation( + id: id, + generation: generation, + phase: .reading + ) + return true + } + + func claimRuntimeAuthReconciliationPublication( + _ reconciliation: RuntimeAuthReconciliation + ) -> PublicationClaim { + guard case .runtimeAuthReconciliation( + let id, + let generation, + .registryEffectClaimed + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + preconditionFailure("Only the active runtime authentication reconciliation can publish.") + } + return publicationClaim + } + + func commitRuntimeAuthReconciliationFailure( + _ reconciliation: RuntimeAuthReconciliation + ) -> Bool { + guard case .runtimeAuthReconciliation( + let id, + let generation, + _ + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + if supersededRuntimeAuthReconciliations.contains(reconciliation.id) { + return false + } + preconditionFailure("Only the active runtime authentication reconciliation can fail closed.") + } + return commitReconciliationFailureIfPublishable() + } + + func finishRuntimeAuthReconciliation(_ reconciliation: RuntimeAuthReconciliation) { + guard case .runtimeAuthReconciliation( + let id, + let generation, + _ + ) = activeTransition, + id == reconciliation.id, + generation == reconciliation.generation else { + if supersededRuntimeAuthReconciliations.remove(reconciliation.id) != nil { + return + } + preconditionFailure("Only the active runtime authentication reconciliation can finish.") + } + finishTransition(id: id) + } + + var acceptsNewOperations: Bool { + activeTransition == nil + && finalShutdownRequested == false + && finalShutdownCompleted == false + && reconciliationFailed == false + } + + var hasActiveLoginTransition: Bool { + switch activeTransition { + case .loginAdmission, .primaryLogin: + true + case .account, .primaryReconciliation, .explicitRuntimeStart, + .runtimeAuthReconciliation, nil: + false + } + } + + func admitPrimaryReconciliation( + _ operation: @escaping @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + ) -> PrimaryReconciliationAdmission { + if case .account = activeTransition { + guard pendingPrimaryReconciliation == nil else { + preconditionFailure("Only one primary authentication reconciliation can queue behind an account transition.") + } + pendingPrimaryReconciliation = makePendingPrimaryReconciliation(operation) + return .accepted + } + guard activeTransition == nil, + finalShutdownRequested == false, + finalShutdownCompleted == false, + reconciliationFailed == false else { + return .deferUntilRuntimeStop + } + startPrimaryReconciliation(operation) + return .accepted + } + + func performStoppedPrimaryReconciliation( + _ operation: @escaping @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + ) async { + let pending = makePendingPrimaryReconciliation(operation) + if case .account = activeTransition { + guard pendingPrimaryReconciliation == nil else { + preconditionFailure("Only one stopped primary reconciliation can queue behind an account transition.") + } + pendingPrimaryReconciliation = pending + } else { + guard activeTransition == nil, + finalShutdownCompleted == false else { + preconditionFailure("A stopped primary reconciliation requires coordinator ownership before final completion.") + } + startPrimaryReconciliation(pending) + } + await pending.completion.wait() + } + + func performFinalShutdown( + _ operation: @escaping @MainActor @Sendable () async -> Bool + ) async -> Bool { + if let finalShutdownCompletion { + return await finalShutdownCompletion.wait() + } + if finalShutdownCompleted { + return true + } + let completion = FinalShutdownCompletion() + finalShutdownCompletion = completion + if finalShutdownRequested == false { + finalShutdownRequested = true + await finalShutdownDidRequest?() + } + while activeTransition != nil { + if case .explicitRuntimeStart(let id, _, commitClaimed: false) = activeTransition { + supersededExplicitRuntimeStarts.insert(id) + finishTransition(id: id) + break + } + if case .runtimeAuthReconciliation( + let id, + _, + .reading + ) = activeTransition { + supersededRuntimeAuthReconciliations.insert(id) + finishTransition(id: id) + break + } + if case .primaryLogin(let id) = activeTransition { + supersededPrimaryLogins.insert(id) + finishTransition(id: id) + break + } + await waitForTransitionCompletion() + } + if finalShutdownCompleted { + completion.resolve(true) + finalShutdownCompletion = nil + return true + } + let didComplete = await operation() + finalShutdownCompleted = didComplete + completion.resolve(didComplete) + finalShutdownCompletion = nil + return didComplete + } + + func waitForFinalShutdownCompletionIfRequested() async { + guard let finalShutdownCompletion else { + return + } + _ = await finalShutdownCompletion.wait() + } + + var isFinalShutdownRequested: Bool { + finalShutdownRequested + } + + func prepareForExplicitRuntimeStart() -> ExplicitRuntimeStart? { + guard activeTransition == nil, + finalShutdownCompletion == nil else { + return nil + } + if finalShutdownCompleted { + finalShutdownRequested = false + finalShutdownCompleted = false + } + guard finalShutdownRequested == false else { + return nil + } + let id = UUID() + activeTransition = .explicitRuntimeStart( + id: id, + repairsReconciliation: reconciliationFailed, + commitClaimed: false + ) + return .init(id: id) + } + + func shouldStageExplicitRuntimeStart(_ start: ExplicitRuntimeStart) -> Bool { + guard case .explicitRuntimeStart(let id, _, let commitClaimed) = activeTransition, + id == start.id else { + return false + } + return commitClaimed || (finalShutdownRequested == false && finalShutdownCompleted == false) + } + + func explicitRuntimeStartRequiresRepair(_ start: ExplicitRuntimeStart) -> Bool { + guard case .explicitRuntimeStart(let id, let repairsReconciliation, _) = activeTransition, + id == start.id else { + return false + } + return repairsReconciliation + } + + func claimExplicitRuntimeStartCommit(_ start: ExplicitRuntimeStart) -> Bool { + guard case .explicitRuntimeStart(let id, let repairsReconciliation, let commitClaimed) = activeTransition, + id == start.id else { + return false + } + if commitClaimed { + return true + } + guard finalShutdownRequested == false, + finalShutdownCompleted == false else { + return false + } + activeTransition = .explicitRuntimeStart( + id: id, + repairsReconciliation: repairsReconciliation, + commitClaimed: true + ) + return true + } + + func finishExplicitRuntimeStart( + _ start: ExplicitRuntimeStart, + didCommitActiveRuntime: Bool + ) { + guard case .explicitRuntimeStart(let id, let repairsReconciliation, let commitClaimed) = activeTransition, + id == start.id else { + if supersededExplicitRuntimeStarts.remove(start.id) != nil { + return + } + preconditionFailure("Only the active explicit runtime start can finish.") + } + if repairsReconciliation, didCommitActiveRuntime { + precondition(commitClaimed, "A reconciliation repair can clear failure only after claiming its runtime commit.") + reconciliationFailed = false + } + finishTransition(id: id) + } + + func commitExplicitRuntimeStartFailure(_ start: ExplicitRuntimeStart) -> Bool { + guard case .explicitRuntimeStart(let id, _, _) = activeTransition, + id == start.id else { + return false + } + return commitReconciliationFailureIfPublishable() + } + + private var publicationClaim: PublicationClaim { + canPublish ? .published : .quiescent + } + + private var canPublish: Bool { + finalShutdownRequested == false + && finalShutdownCompleted == false + && reconciliationFailed == false + } + + private func commitReconciliationFailureIfPublishable() -> Bool { + guard finalShutdownRequested == false, + finalShutdownCompleted == false else { + return false + } + reconciliationFailed = true + return true + } + + private func waitForTransitionCompletion() async { + guard activeTransition != nil else { + return + } + await withCheckedContinuation { continuation in + transitionCompletionWaiters.append(continuation) + } + } + + private func finishTransition(id: UUID) { + guard activeTransition?.id == id else { + preconditionFailure("Only the active account runtime transition can finish.") + } + if case .account = activeTransition, + let pendingPrimaryReconciliation { + self.pendingPrimaryReconciliation = nil + activeTransition = nil + startPrimaryReconciliation(pendingPrimaryReconciliation) + return + } + let primaryReconciliationCompletion: PrimaryReconciliationCompletion? = + if case .primaryReconciliation(_, let completion) = activeTransition { + completion + } else { + nil + } + activeTransition = nil + primaryReconciliationCompletion?.resolve() + let waiters = transitionCompletionWaiters + transitionCompletionWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + didBecomeIdle?() + } + + private func startPrimaryReconciliation( + _ operation: @escaping @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + ) { + startPrimaryReconciliation(makePendingPrimaryReconciliation(operation)) + } + + private func makePendingPrimaryReconciliation( + _ operation: @escaping @MainActor @Sendable ( + PrimaryReconciliationReservation + ) async -> Void + ) -> PendingPrimaryReconciliation { + let id = UUID() + return .init( + reservation: .init(id: id), + completion: .init(), + operation: operation + ) + } + + private func startPrimaryReconciliation( + _ pending: PendingPrimaryReconciliation + ) { + precondition(activeTransition == nil) + let id = pending.reservation.id + activeTransition = .primaryReconciliation( + id: id, + completion: pending.completion + ) + Task { @MainActor [weak self] in + await pending.operation(pending.reservation) + self?.finishTransition(id: id) + } + } +} diff --git a/Sources/CodexReviewHost/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift deleted file mode 100644 index 8f9a7480..00000000 --- a/Sources/CodexReviewHost/CodexReviewHost.swift +++ /dev/null @@ -1,303 +0,0 @@ -import Foundation -import CodexReviewKit -import CodexReviewMCPServer - -@MainActor -package final class CodexReviewHost { - package let store: CodexReviewStore - package let mcpServer: CodexReviewMCPServer - private let shutdown: @Sendable () async -> Void - private var endpoint: URL? - - package init( - backend: any CodexReviewBackend, - clock: CodexReviewClock = .init(), - idGenerator: CodexReviewIDGenerator = .init(), - endpoint: URL? = nil, - shutdown: @escaping @Sendable () async -> Void = {} - ) { - self.shutdown = shutdown - self.endpoint = endpoint - let store = CodexReviewStore( - backend: DirectCodexReviewStoreBackend(backend: backend), - clock: clock, - idGenerator: idGenerator - ) - self.store = store - self.mcpServer = CodexReviewMCPServer(store: store) - } - - package func start(endpoint: URL? = nil) async { - if let endpoint { - self.endpoint = endpoint - } - store.transitionToRunning(serverURL: self.endpoint) - await store.refreshSettings() - } - - package func stop() async { - await store.stop() - await shutdown() - } -} - -@MainActor -private final class DirectCodexReviewStoreBackend: CodexReviewStoreBackend { - let seed = CodexReviewStoreSeed() - private let backend: any CodexReviewBackend - private var currentSettingsSnapshot = CodexReviewSettings.Snapshot() - private var loginChallenge: CodexReviewBackendModel.Login.Challenge? - private var active = false - - var isActive: Bool { - active - } - - var initialSettingsSnapshot: CodexReviewSettings.Snapshot { - currentSettingsSnapshot - } - - init(backend: any CodexReviewBackend) { - self.backend = backend - } - - func attachStore(_: CodexReviewStore) {} - - func start(store _: CodexReviewStore, forceRestartIfNeeded _: Bool) async { - active = true - } - - func stop(store _: CodexReviewStore) async { - active = false - } - - func waitUntilStopped() async {} - - func refreshSettings() async throws -> CodexReviewSettings.Snapshot { - currentSettingsSnapshot = try await Self.monitorSettings(from: backend.readSettings()) - return currentSettingsSnapshot - } - - func updateSettingsModel( - _ model: String?, - reasoningEffort: CodexReviewSettings.ReasoningEffort?, - persistReasoningEffort: Bool, - serviceTier: CodexReviewSettings.ServiceTier?, - persistServiceTier: Bool - ) async throws { - var change = CodexReviewBackendModel.Settings.Change( - model: model, - updatesModel: true - ) - if persistReasoningEffort { - change.reasoningEffort = reasoningEffort?.rawValue - change.updatesReasoningEffort = true - } - if persistServiceTier { - change.serviceTier = serviceTier?.rawValue - change.updatesServiceTier = true - } - currentSettingsSnapshot = try await Self.monitorSettings(from: backend.applySettings(change)) - } - - func updateSettingsReasoningEffort( - _ reasoningEffort: CodexReviewSettings.ReasoningEffort? - ) async throws { - currentSettingsSnapshot = try await Self.monitorSettings( - from: backend.applySettings(.init( - reasoningEffort: reasoningEffort?.rawValue, - updatesReasoningEffort: true - )) - ) - } - - func updateSettingsServiceTier( - _ serviceTier: CodexReviewSettings.ServiceTier? - ) async throws { - currentSettingsSnapshot = try await Self.monitorSettings( - from: backend.applySettings(.init( - serviceTier: serviceTier?.rawValue, - updatesServiceTier: true - )) - ) - } - - func refreshAuth(auth: CodexReviewAuthModel) async { - do { - Self.applyAuthSnapshot(try await backend.readAuth(), to: auth) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) - } - } - - func signIn(auth: CodexReviewAuthModel) async { - do { - let challenge = try await backend.startLogin(.init()) - loginChallenge = challenge - auth.updatePhase(.signingIn(.init( - title: "Sign in to Codex", - detail: challenge.signInDetail(nativeAuthentication: false), - browserURL: challenge.verificationURL?.absoluteString, - userCode: challenge.userCode - ))) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) - } - } - - func addAccount(auth: CodexReviewAuthModel) async { - await signIn(auth: auth) - } - - func cancelAuthentication(auth: CodexReviewAuthModel) async { - defer { loginChallenge = nil } - guard let loginChallenge else { - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) - return - } - do { - try await backend.cancelLogin(loginChallenge) - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) - } - } - - func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { - guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { - return - } - auth.applyPersistedAccountStates( - auth.persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: accountKey - ) - auth.selectPersistedAccount(auth.persistedAccounts.first(where: { $0.accountKey == accountKey })?.id) - auth.updatePhase(.signedOut) - } - - func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { - let remaining = auth.persistedAccounts.filter { $0.accountKey != accountKey } - auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:))) - if auth.selectedAccount?.accountKey == accountKey { - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - } - } - - func reorderPersistedAccount( - auth: CodexReviewAuthModel, - accountKey: String, - toIndex: Int - ) async throws { - var accounts = auth.persistedAccounts - guard let sourceIndex = accounts.firstIndex(where: { $0.accountKey == accountKey }) else { - return - } - let destinationIndex = max(0, min(toIndex, accounts.count - 1)) - guard sourceIndex != destinationIndex else { - return - } - let account = accounts.remove(at: sourceIndex) - accounts.insert(account, at: destinationIndex) - auth.applyPersistedAccountStates(accounts.map(savedAccountPayload(from:))) - } - - func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { - if let account = auth.selectedAccount { - _ = try await backend.logout(.init(account.accountKey)) - } - auth.updatePhase(.signedOut) - auth.selectPersistedAccount(nil) - auth.applyPersistedAccountStates([]) - } - - func refreshAccountRateLimits(auth _: CodexReviewAuthModel, accountKey _: String) async {} - - func requiresCurrentSessionRecovery(auth _: CodexReviewAuthModel, accountKey _: String) -> Bool { - false - } - - func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { - try await backend.startReview(request) - } - - func interruptReview( - _ run: CodexReviewBackendModel.Review.Run, - reason: CodexReviewBackendModel.CancellationReason - ) async throws { - try await backend.interruptReview(run, reason: reason) - } - - func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken { - try await backend.prepareReviewRestart(run) - } - - func restartPreparedReview( - _ token: CodexReviewBackendModel.Review.RestartToken, - request: CodexReviewBackendModel.Review.Start - ) async throws -> BackendReviewAttempt { - try await backend.restartPreparedReview(token, request: request) - } - - func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - await backend.cleanupReview(run) - } - - private static func monitorSettings( - from snapshot: CodexReviewBackendModel.Settings.Snapshot - ) -> CodexReviewSettings.Snapshot { - .init( - model: snapshot.model, - fallbackModel: snapshot.fallbackModel, - reasoningEffort: snapshot.reasoningEffort.flatMap(CodexReviewSettings.ReasoningEffort.init(rawValue:)), - serviceTier: snapshot.serviceTier.flatMap(CodexReviewSettings.ServiceTier.init(rawValue:)), - models: snapshot.models - ) - } - - private static func applyAuthSnapshot( - _ snapshot: CodexReviewBackendModel.Auth.Snapshot, - to auth: CodexReviewAuthModel - ) { - let accounts = snapshot.accounts.compactMap { account -> CodexReviewAccount? in - let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) - let accountKey = CodexReviewAccount.normalizedEmail(account.id.rawValue) - guard label.isEmpty == false, accountKey.isEmpty == false else { - return nil - } - return CodexReviewAccount( - accountKey: accountKey, - email: label, - planType: account.planType, - kind: account.kind, - capabilities: account.capabilities - ) - } - let activeAccountKey = snapshot.activeAccountID - .map { CodexReviewAccount.normalizedEmail($0.rawValue) } - auth.applyPersistedAccountStates( - accounts.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) - if let activeAccountKey, - let account = accounts.first(where: { $0.accountKey == activeAccountKey }) - { - auth.selectPersistedAccount(account.id) - auth.updatePhase(.signedOut) - } else { - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - } - } -} - -extension CodexReviewBackendModel.Login.Challenge { - func signInDetail(nativeAuthentication: Bool) -> String { - if let userCode = userCode?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty { - return "Enter code \(userCode) in your browser, then return to ReviewMonitor." - } - return nativeAuthentication - ? "Complete sign in in the authentication window." - : "Complete sign in in your browser, then return to ReviewMonitor." - } -} diff --git a/Sources/CodexReviewHost/CodexReviewNativeAuthentication.swift b/Sources/CodexReviewHost/CodexReviewNativeAuthentication.swift deleted file mode 100644 index 52f0a54c..00000000 --- a/Sources/CodexReviewHost/CodexReviewNativeAuthentication.swift +++ /dev/null @@ -1,194 +0,0 @@ -import AppKit -import AuthenticationServices -import Foundation -import OSLog - -private let logger = Logger(subsystem: "CodexReviewKit", category: "native-auth") - -public enum CodexReviewNativeAuthentication {} - -@MainActor -public extension CodexReviewNativeAuthentication { - struct Configuration: Sendable { - public enum BrowserSessionPolicy: Sendable { - case ephemeral - } - - public var callbackScheme: String - public var browserSessionPolicy: BrowserSessionPolicy - public var presentationAnchorProvider: @MainActor @Sendable () -> ASPresentationAnchor? - - public init( - callbackScheme: String, - browserSessionPolicy: BrowserSessionPolicy, - presentationAnchorProvider: @escaping @MainActor @Sendable () -> ASPresentationAnchor? - ) { - self.callbackScheme = callbackScheme - self.browserSessionPolicy = browserSessionPolicy - self.presentationAnchorProvider = presentationAnchorProvider - } - } -} - -@MainActor -public extension CodexReviewNativeAuthentication { - protocol WebSession: AnyObject, Sendable { - func waitForCallbackURL() async throws -> URL - func cancel() async - } -} - -public extension CodexReviewNativeAuthentication { - typealias WebSessionFactory = @MainActor @Sendable ( - URL, - String, - Configuration.BrowserSessionPolicy, - @escaping @MainActor @Sendable () -> ASPresentationAnchor? - ) async throws -> any WebSession -} - -public extension CodexReviewNativeAuthentication { - enum WebSessions { - public static let system: WebSessionFactory = { - url, - callbackScheme, - browserSessionPolicy, - presentationAnchorProvider in - try await SystemCodexReviewWebAuthenticationSession.start( - using: url, - callbackScheme: callbackScheme, - browserSessionPolicy: browserSessionPolicy, - presentationAnchorProvider: presentationAnchorProvider - ) - } - } -} - -@MainActor -private final class SystemCodexReviewWebAuthenticationSession: NSObject, CodexReviewNativeAuthentication.WebSession { - private final class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { - let anchor: ASPresentationAnchor - - init(anchor: ASPresentationAnchor) { - self.anchor = anchor - } - - func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor { - anchor - } - } - - private var session: ASWebAuthenticationSession? - private var provider: PresentationContextProvider? - private var continuation: CheckedContinuation? - private var result: Result? - - static func start( - using url: URL, - callbackScheme: String, - browserSessionPolicy: CodexReviewNativeAuthentication.Configuration.BrowserSessionPolicy, - presentationAnchorProvider: @escaping @MainActor @Sendable () -> ASPresentationAnchor? - ) async throws -> SystemCodexReviewWebAuthenticationSession { - guard let anchor = presentationAnchorProvider() else { - throw CodexReviewNativeAuthenticationError.loginFailed( - "Unable to present authentication session." - ) - } - - let activeSession = SystemCodexReviewWebAuthenticationSession() - let provider = PresentationContextProvider(anchor: anchor) - let session = ASWebAuthenticationSession( - url: url, - callback: .customScheme(callbackScheme), - completionHandler: makeSystemCodexReviewWebAuthenticationCompletionHandler(activeSession) - ) - switch browserSessionPolicy { - case .ephemeral: - session.prefersEphemeralWebBrowserSession = true - } - session.presentationContextProvider = provider - activeSession.session = session - activeSession.provider = provider - - logger.info("Starting ASWebAuthenticationSession") - guard session.start() else { - activeSession.finishAuthenticationSession(with: .failure(CodexReviewNativeAuthenticationError.loginFailed( - "Unable to start authentication session." - ))) - throw CodexReviewNativeAuthenticationError.loginFailed( - "Unable to start authentication session." - ) - } - return activeSession - } - - func waitForCallbackURL() async throws -> URL { - if let result { - return try result.get() - } - return try await withCheckedThrowingContinuation { continuation in - if let result { - continuation.resume(with: result) - return - } - self.continuation = continuation - } - } - - func cancel() async { - logger.info("Cancelling ASWebAuthenticationSession") - session?.cancel() - } - - fileprivate func finishAuthenticationSession(with result: Result) { - guard self.result == nil else { - return - } - self.result = result - session = nil - provider = nil - continuation?.resume(with: result) - continuation = nil - } -} - -private func makeSystemCodexReviewWebAuthenticationCompletionHandler( - _ activeSession: SystemCodexReviewWebAuthenticationSession -) -> ASWebAuthenticationSession.CompletionHandler { - { [weak activeSession] callbackURL, error in - let mappedResult = mapAuthenticationResult(callbackURL: callbackURL, error: error) - Task { @MainActor [weak activeSession] in - activeSession?.finishAuthenticationSession(with: mappedResult) - } - } -} - -package enum CodexReviewNativeAuthenticationError: LocalizedError, Sendable { - case cancelled - case loginFailed(String) - - package var errorDescription: String? { - switch self { - case .cancelled: - "Authentication was cancelled." - case .loginFailed(let message): - message - } - } -} - -private func mapAuthenticationResult(callbackURL: URL?, error: Error?) -> Result { - if let callbackURL { - return .success(callbackURL) - } - if let error { - let nsError = error as NSError - if nsError.domain == ASWebAuthenticationSessionErrorDomain, - nsError.code == ASWebAuthenticationSessionError.Code.canceledLogin.rawValue - { - return .failure(CodexReviewNativeAuthenticationError.cancelled) - } - return .failure(CodexReviewNativeAuthenticationError.loginFailed(error.localizedDescription)) - } - return .failure(CodexReviewNativeAuthenticationError.cancelled) -} diff --git a/Sources/CodexReviewHost/HostRuntimeSession.swift b/Sources/CodexReviewHost/HostRuntimeSession.swift new file mode 100644 index 00000000..937c49d7 --- /dev/null +++ b/Sources/CodexReviewHost/HostRuntimeSession.swift @@ -0,0 +1,430 @@ +import Foundation +import OSLog +import CodexAppServerKit +import CodexDataKit +import CodexReviewKit +import CodexReviewAppServer + +private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") + +struct HostRuntimeConsumerFailure: Error, LocalizedError, Sendable { + let message: String + + var errorDescription: String? { + message + } +} + +@MainActor +struct AppServerRuntime: Sendable { + var appServer: CodexAppServer + var modelContainer: CodexModelContainer + var backend: AppServerCodexReviewBackend +} + +struct HostRuntimeStopResult: Sendable { + var didReleaseResources: Bool + var didRetireRuns: Bool + var primaryAuthenticationHandoff: PrimaryAuthenticationReconciliationHandoff? +} + +enum RuntimeAccountObservation: Equatable, Sendable { + case signedOut + case account( + accountKey: String, + provider: ExpectedRuntimeAccount.Provider + ) + case invalid + + var exactExpectation: ExpectedRuntimeAccount? { + switch self { + case .signedOut: + .signedOut + case .account(let accountKey, let provider): + .observedAccount(accountKey: accountKey, provider: provider) + case .invalid: + nil + } + } +} + +struct RuntimeAccountObservationAuthorization: Equatable, Sendable { + let generation: UInt64 + let revision: UInt64 + let observation: RuntimeAccountObservation +} + +@MainActor +final class HostRuntimeSession { + enum Phase { + case staging + case active + case stopping + case stopIncomplete + case stopped + } + + let generation: UInt64 + + private(set) var phase: Phase = .staging + private(set) var runtime: AppServerRuntime? + private(set) var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? + private(set) var accountEvents: CodexAccountEvents? + private(set) var accountConsumerTask: Task? + private(set) var connectionEvents: CodexConnectionEvents? + private(set) var connectionConsumerTask: Task? + private(set) var stopTask: Task? + private(set) var shouldRetireRuns = false + private(set) var accountObservation: RuntimeAccountObservation? + private(set) var accountObservationRevision: UInt64? + private(set) var accountInvalidationRevision: UInt64 = 0 + + private let lifecycleHandler: CodexReviewAppServerLifecycleHandler? + private let finalRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? + private var didPublishLifecycle = false + private var admissionOpen = false + private var stagingFailure: HostRuntimeConsumerFailure? + private var didConsumePrimaryAuthenticationHandoff = false + private var pendingPrimaryAuthenticationHandoff: PrimaryAuthenticationReconciliationHandoff? + private var finalRetirementClaimTask: Task? + + init( + generation: UInt64, + lifecycleHandler: CodexReviewAppServerLifecycleHandler?, + finalRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? + ) { + self.generation = generation + self.lifecycleHandler = lifecycleHandler + self.finalRetirementDidClaim = finalRetirementDidClaim + } + + var activeRuntime: AppServerRuntime? { + guard case .active = phase, admissionOpen else { + return nil + } + return runtime + } + + var activeMCPHTTPServer: (any CodexReviewMCPHTTPServing)? { + guard case .active = phase, admissionOpen else { + return nil + } + return mcpHTTPServer + } + + var isActive: Bool { + if case .active = phase, admissionOpen { + return true + } + return false + } + + var hasCurrentAccountObservation: Bool { + isActive + && accountObservation != nil + && accountObservationRevision == accountInvalidationRevision + } + + func installRuntime(_ runtime: AppServerRuntime) { + precondition(self.runtime == nil, "A Host runtime session can install its app-server runtime only once.") + precondition(isStaging, "An app-server runtime can be installed only while staging.") + self.runtime = runtime + } + + func installMCPHTTPServer(_ server: any CodexReviewMCPHTTPServing) { + precondition(mcpHTTPServer == nil, "A Host runtime session can install its MCP server only once.") + precondition(isStaging, "An MCP server can be installed only while staging.") + mcpHTTPServer = server + } + + func recordAccountObservation( + _ observation: RuntimeAccountObservation, + revision: UInt64 + ) { + precondition(accountObservation == nil, "A Host runtime generation validates its account snapshot only once.") + precondition(isStaging, "A runtime account observation belongs to staging validation.") + precondition( + revision == accountInvalidationRevision, + "A staging runtime can publish only its latest account observation." + ) + accountObservation = observation + accountObservationRevision = revision + } + + func updateAccountObservation( + _ observation: RuntimeAccountObservation, + revision: UInt64 + ) { + precondition(isActive, "Only an active runtime can update its account observation.") + precondition( + revision == accountInvalidationRevision, + "An active runtime can publish only its latest account observation." + ) + accountObservation = observation + accountObservationRevision = revision + } + + func recordAccountInvalidation() { + precondition( + accountInvalidationRevision < .max, + "A Host runtime account invalidation revision must not wrap." + ) + accountInvalidationRevision += 1 + } + + func authorizeRateLimitObservation( + for account: CodexReviewAccount + ) -> RuntimeAccountObservationAuthorization? { + guard isActive, + let accountObservationRevision, + accountObservationRevision == accountInvalidationRevision else { + return nil + } + let expectedObservation = RuntimeAccountObservation.account( + accountKey: account.accountKey, + provider: .init(account.kind) + ) + guard accountObservation == expectedObservation else { + return nil + } + return .init( + generation: generation, + revision: accountObservationRevision, + observation: expectedObservation + ) + } + + func validatesRateLimitObservation( + _ authorization: RuntimeAccountObservationAuthorization + ) -> Bool { + isActive + && authorization.generation == generation + && authorization.revision == accountInvalidationRevision + && authorization.revision == accountObservationRevision + && authorization.observation == accountObservation + } + + func installConsumers( + accountEvents: CodexAccountEvents, + connectionEvents: CodexConnectionEvents, + accountEventSink: @escaping @MainActor @Sendable (CodexAccountEvent) async -> Void, + exitSink: @escaping @MainActor @Sendable (HostRuntimeConsumerFailure) async -> Void + ) { + precondition( + self.accountEvents == nil && accountConsumerTask == nil + && self.connectionEvents == nil && connectionConsumerTask == nil, + "A Host runtime session can install its event consumers only once." + ) + precondition(isStaging, "Runtime consumers can be installed only while staging.") + self.accountEvents = accountEvents + accountConsumerTask = Task { @MainActor in + do { + for try await event in accountEvents { + await accountEventSink(event) + } + if Task.isCancelled == false { + await exitSink(.init(message: "The Codex account event stream ended unexpectedly.")) + } + } catch is CancellationError { + } catch { + logger.error("Auth notification stream ended: \(error.localizedDescription, privacy: .public)") + await exitSink(.init(message: error.localizedDescription)) + } + } + self.connectionEvents = connectionEvents + connectionConsumerTask = Task { @MainActor in + for await event in connectionEvents { + switch event { + case .warning(let diagnostic): + logger.warning("App-server warning: \(diagnostic.message, privacy: .public)") + case .retrying(let diagnostic): + logger.warning("App-server retrying \(diagnostic.method, privacy: .public) attempt \(diagnostic.attempt, privacy: .public)") + case .deprecation(let notice): + logger.warning("App-server deprecation: \(notice.summary, privacy: .public)") + case .unknown: + logger.debug("Unknown app-server notification") + case .terminated(let termination): + await exitSink(.init(message: Self.failureMessage(for: termination))) + return + } + } + if Task.isCancelled == false { + await exitSink(.init(message: "The Codex connection event stream ended unexpectedly.")) + } + } + } + + func commit() { + precondition(isStaging, "Only a staged Host runtime session can become active.") + guard let modelContainer = runtime?.modelContainer else { + preconditionFailure("A Host runtime session requires a model container before publication.") + } + phase = .active + admissionOpen = true + didPublishLifecycle = true + lifecycleHandler?(modelContainer) + } + + func recordStagingFailure(_ failure: HostRuntimeConsumerFailure) { + guard isStaging, stagingFailure == nil else { + return + } + stagingFailure = failure + } + + func requireHealthyStaging() throws { + guard isStaging else { + throw HostRuntimeConsumerFailure(message: "The Host runtime staging generation was superseded.") + } + if let stagingFailure { + throw stagingFailure + } + } + + func beginStopping() { + switch phase { + case .staging, .active, .stopIncomplete: + phase = .stopping + case .stopping, .stopped: + return + } + closeAdmission() + } + + func closeAdmission() { + admissionOpen = false + if didPublishLifecycle { + didPublishLifecycle = false + lifecycleHandler?(nil) + } + } + + func cancelConsumersAndWait() async { + await connectionEvents?.cancel() + await accountEvents?.cancel() + connectionConsumerTask?.cancel() + accountConsumerTask?.cancel() + await connectionConsumerTask?.value + await accountConsumerTask?.value + connectionEvents = nil + connectionConsumerTask = nil + accountEvents = nil + accountConsumerTask = nil + } + + func waitForStopCompletion() async -> HostRuntimeStopResult? { + guard let stopTask else { + return nil + } + return await stopTask.value + } + + func waitForFinalRetirementClaim() async { + await finalRetirementClaimTask?.value + } + + func takePrimaryAuthenticationHandoff( + from result: HostRuntimeStopResult + ) -> PrimaryAuthenticationReconciliationHandoff? { + guard didConsumePrimaryAuthenticationHandoff == false else { + return nil + } + let handoff = pendingPrimaryAuthenticationHandoff ?? result.primaryAuthenticationHandoff + guard let handoff else { return nil } + didConsumePrimaryAuthenticationHandoff = true + pendingPrimaryAuthenticationHandoff = nil + return handoff + } + + func retainPrimaryAuthenticationHandoffForStop( + _ handoff: PrimaryAuthenticationReconciliationHandoff? + ) { + guard let handoff else { return } + guard pendingPrimaryAuthenticationHandoff == nil else { + preconditionFailure("A Host runtime session can retain only one primary authentication handoff.") + } + pendingPrimaryAuthenticationHandoff = handoff + } + + func finishStopping(didReleaseResources: Bool) { + precondition(stopTask == nil, "The shared stop task must clear itself before stop completion is published.") + if didReleaseResources { + runtime = nil + mcpHTTPServer = nil + accountEvents = nil + accountConsumerTask = nil + connectionEvents = nil + connectionConsumerTask = nil + accountObservation = nil + accountObservationRevision = nil + phase = .stopped + } else { + phase = .stopIncomplete + } + } + + func requestStop( + purpose: CodexReviewRuntimeStopPurpose, + _ operation: @escaping @MainActor @Sendable (HostRuntimeSession) async -> HostRuntimeStopResult + ) -> Task? { + if purpose.retiresRuns, shouldRetireRuns == false { + shouldRetireRuns = true + let finalRetirementDidClaim = finalRetirementDidClaim + finalRetirementClaimTask = Task { @MainActor in + await finalRetirementDidClaim?() + } + } + if let stopTask { + return stopTask + } + switch phase { + case .stopped: + return nil + case .stopping: + preconditionFailure("A stopping Host runtime session must retain its shared stop completion.") + case .staging, .active, .stopIncomplete: + break + } + beginStopping() + let task: Task = Task { @MainActor [weak self] in + guard let self else { + return .init( + didReleaseResources: true, + didRetireRuns: false, + primaryAuthenticationHandoff: nil + ) + } + await self.finalRetirementClaimTask?.value + let result = await operation(self) + self.stopTask = nil + self.finishStopping(didReleaseResources: result.didReleaseResources) + return result + } + stopTask = task + return task + } + + var isStaging: Bool { + if case .staging = phase { + return true + } + return false + } + + private nonisolated static func failureMessage( + for termination: CodexConnectionTermination + ) -> String { + switch termination { + case .closedByCaller: + "The Codex app-server connection was closed by the caller." + case .transportFailure(let failure): + failure.localizedDescription + case .processExited(let status): + if let status { + "The Codex app-server process exited with status \(status)." + } else { + "The Codex app-server process exited." + } + } + } +} diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index fedc3879..d46bdf8c 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,81 +1,25 @@ import AppKit import Foundation import OSLog -import CodexKit import CodexAppServerKit +import CodexDataKit import CodexReviewKit import CodexReviewAppServer import CodexReviewMCPServer private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") -private typealias ExternalURLOpener = @MainActor @Sendable (URL) -> Void +package typealias ExternalURLOpener = @MainActor @Sendable (URL) throws -> Void public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexModelContainer?) -> Void private let defaultExternalURLOpener: ExternalURLOpener = { url in - _ = NSWorkspace.shared.open(url) -} - -private actor RuntimeShutdownCleanupRace { - private var result: Bool? - private var continuation: CheckedContinuation? - - func finish(_ value: Bool) { - guard result == nil else { - return - } - result = value - continuation?.resume(returning: value) - continuation = nil - } - - func wait() async -> Bool { - if let result { - return result - } - return await withCheckedContinuation { continuation in - if let result { - continuation.resume(returning: result) - } else { - self.continuation = continuation - } - } - } -} - -private func runRuntimeShutdownCleanup( - timeout: Duration, - operation: @escaping @Sendable () async -> Void -) async -> Bool { - let race = RuntimeShutdownCleanupRace() - let operationTask = Task { - await operation() - await race.finish(true) - } - let timeoutTask = Task { - do { - try await Task.sleep(for: timeout) - } catch { - return - } - await race.finish(false) - } - let result = await race.wait() - if result { - timeoutTask.cancel() - } else { - operationTask.cancel() + guard NSWorkspace.shared.open(url) else { + throw CodexReviewAuthenticationFailure.urlOpen(url) } - return result } -private struct PendingLoginRuntimeCleanup { - var appServer: CodexAppServer? - var codexHomeURL: URL? - var authenticationSession: (any CodexReviewNativeAuthentication.WebSession)? - - var isEmpty: Bool { - appServer == nil && codexHomeURL == nil && authenticationSession == nil - } +private enum RuntimeReviewCleanupMode { + case connected + case connectionTerminated } package struct CodexReviewMCPPortOwner: Equatable, Sendable { @@ -96,27 +40,44 @@ package typealias CodexReviewMCPHTTPServerBindChecker = @MainActor @Sendable ( CodexReviewMCPHTTPServer.Configuration ) async throws -> Void +package typealias CodexReviewAuthenticationMutationDidBegin = @Sendable () async -> Void +package typealias CodexReviewAuthenticationCancellationDidRequest = @Sendable () async -> Void +package typealias CodexReviewAuthenticationProductCommitDidApply = @Sendable () async -> Void +package typealias CodexReviewAuthenticationHandleDidBind = @Sendable () async -> Void +package typealias CodexReviewAccountRegistryLoadDidBegin = @Sendable () async -> Void +package typealias CodexReviewAppServerCloser = @MainActor @Sendable (CodexAppServer) async -> Void +package typealias CodexReviewFinalRuntimeRetirementDidClaim = @MainActor @Sendable () async -> Void +package typealias CodexReviewFinalShutdownDidRequest = @MainActor @Sendable () async -> Void +package typealias CodexReviewReconciliationDebtDidClear = @MainActor @Sendable (CodexAppServer) async -> Void +package typealias CodexReviewRegistryDestinationDidReplace = @Sendable () throws -> Void + package protocol CodexReviewMCPHTTPServing: AnyObject, Sendable { var url: URL { get async } func start() async throws + func stage() async throws + func activate() async func stop() async } +extension CodexReviewMCPHTTPServing { + package func stage() async throws { + try await start() + } + + package func activate() async {} +} + extension CodexReviewMCPHTTPServer: CodexReviewMCPHTTPServing {} @MainActor public extension CodexReviewStore { static func makeLiveStore( runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory = CodexReviewNativeAuthentication.WebSessions.system, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil ) -> CodexReviewStore { CodexReviewStore(backend: LiveCodexReviewStoreBackend( runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, appServerLifecycleHandler: appServerLifecycleHandler )) } @@ -124,29 +85,43 @@ public extension CodexReviewStore { package static func makeLiveStoreForTesting( environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory, - externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = defaultExternalURLOpener, + externalURLOpener: @escaping ExternalURLOpener = defaultExternalURLOpener, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServer: CodexAppServer, - appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil + appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? = nil, + accountRegistryLoadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil, + finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? = nil, + finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil, + reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + appServerCloser: @escaping CodexReviewAppServerCloser = { await $0.close() } ) -> CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, + authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationCancellationDidRequest: authenticationCancellationDidRequest, + authenticationProductCommitDidApply: authenticationProductCommitDidApply, + authenticationHandleDidBind: authenticationHandleDidBind, + accountRegistryLoadDidBegin: accountRegistryLoadDidBegin, + finalRuntimeRetirementDidClaim: finalRuntimeRetirementDidClaim, + finalShutdownDidRequest: finalShutdownDidRequest, + reconciliationDebtDidClear: reconciliationDebtDidClear, + registryDestinationDidReplace: registryDestinationDidReplace, + appServerCloser: appServerCloser, appServerFactory: { _ in appServer } ) } @@ -154,9 +129,7 @@ public extension CodexReviewStore { package static func makeLiveStoreForTesting( environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory, - externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = defaultExternalURLOpener, + externalURLOpener: @escaping ExternalURLOpener = defaultExternalURLOpener, mcpHTTPServerFactory: (@MainActor @Sendable ( CodexReviewStore, CodexReviewMCPHTTPServer.Configuration, @@ -164,24 +137,40 @@ public extension CodexReviewStore { ) -> any CodexReviewMCPHTTPServing)? = nil, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? = nil, + accountRegistryLoadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil, + finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? = nil, + finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil, + reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + appServerCloser: @escaping CodexReviewAppServerCloser = { await $0.close() }, appServerFactory: @escaping @MainActor @Sendable (URL) async throws -> CodexAppServer ) -> CodexReviewStore { CodexReviewStore( backend: LiveCodexReviewStoreBackend( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, appServerLifecycleHandler: appServerLifecycleHandler, + authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationCancellationDidRequest: authenticationCancellationDidRequest, + authenticationProductCommitDidApply: authenticationProductCommitDidApply, + authenticationHandleDidBind: authenticationHandleDidBind, + accountRegistryLoadDidBegin: accountRegistryLoadDidBegin, + finalRuntimeRetirementDidClaim: finalRuntimeRetirementDidClaim, + finalShutdownDidRequest: finalShutdownDidRequest, + reconciliationDebtDidClear: reconciliationDebtDidClear, + registryDestinationDidReplace: registryDestinationDidReplace, + appServerCloser: appServerCloser, appServerRuntimeFactory: { codexHomeURL in let appServer = try await appServerFactory(codexHomeURL) let modelContainer = CodexModelContainer(appServer: appServer) @@ -189,7 +178,6 @@ public extension CodexReviewStore { appServer: appServer, modelContainer: modelContainer, backend: AppServerCodexReviewBackend( - appServer: appServer, modelContainer: modelContainer ) ) @@ -209,41 +197,154 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ReviewMCPLogProjectionProvider? ) -> any CodexReviewMCPHTTPServing + private enum RuntimePublicationOwner { + case explicit(AccountRuntimeTransitionCoordinator.ExplicitRuntimeStart) + case account(AccountRuntimeTransitionCoordinator.AccountTransition) + case primary(AccountRuntimeTransitionCoordinator.PrimaryReconciliationReservation) + } + + private enum RuntimeStartMode { + case published(owner: RuntimePublicationOwner) + case quiescentReconciliation + + var explicitStart: AccountRuntimeTransitionCoordinator.ExplicitRuntimeStart? { + guard case .published(.explicit(let explicitStart)) = self else { + return nil + } + return explicitStart + } + + var requestsPublication: Bool { + if case .published = self { + return true + } + return false + } + } + + private enum RuntimeAuthReconciliationCause { + case manualRefresh + case accountUpdated + } + + private enum ActiveRateLimitRefreshFailure: Error { + case staleAccountIdentity + } + + @MainActor + private final class AccountMutationContext { + let lease: AccountRegistryStore.MutationLease + let before: AccountRegistryStore.Snapshot + private(set) var recoveryExpectation: ExpectedRuntimeAccount + private let admittedRuntimeGeneration: UInt64? + private let admittedRuntimeInvalidationRevision: UInt64? + private var recoversAdmittedRuntime = true + + init( + _ mutation: AccountRegistryStore.AccountMutation, + admittedRuntimeGeneration: UInt64?, + admittedRuntimeInvalidationRevision: UInt64? + ) { + lease = mutation.lease + before = mutation.before + recoveryExpectation = mutation.before.expectedRuntimeAccount + self.admittedRuntimeGeneration = admittedRuntimeGeneration + self.admittedRuntimeInvalidationRevision = admittedRuntimeInvalidationRevision + } + + func expectRecovery(_ expectation: ExpectedRuntimeAccount) { + recoveryExpectation = expectation + recoversAdmittedRuntime = false + } + + func expectBeforeRecovery() { + recoveryExpectation = before.expectedRuntimeAccount + recoversAdmittedRuntime = true + } + + func expectationForUnconfirmedMutation( + currentRuntimeSession: HostRuntimeSession? + ) -> ExpectedRuntimeAccount { + guard recoversAdmittedRuntime, + let admittedRuntimeGeneration, + let admittedRuntimeInvalidationRevision else { + return recoveryExpectation + } + guard currentRuntimeSession?.generation == admittedRuntimeGeneration, + currentRuntimeSession?.accountInvalidationRevision + == admittedRuntimeInvalidationRevision else { + return .reconcileCurrentRuntime + } + return recoveryExpectation + } + } + let seed: CodexReviewStoreSeed - private var appServer: CodexAppServer? - private var appServerModelContainer: CodexModelContainer? - private var appServerBackend: AppServerCodexReviewBackend? - private var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? - private var loginChallenge: CodexReviewBackendModel.Login.Challenge? - private var loginBackend: AppServerCodexReviewBackend? - private var loginAppServer: CodexAppServer? - private var loginCodexHomeURL: URL? - private var loginActivation: LoginActivation = .activateAuthenticatedAccount - private var isWaitingForLoginAccountUpdate = false - private var activeAuthenticationSession: (any CodexReviewNativeAuthentication.WebSession)? - private var authenticationTask: Task? - private var authNotificationTask: Task? - private var loginNotificationTask: Task? + private var runtimeSession: HostRuntimeSession? + private var nextRuntimeGeneration: UInt64 = 1 + private var loginSession: LoginSession? + private var primaryLoginAdmission: ( + generationID: UUID, + admission: AccountRuntimeTransitionCoordinator.LoginAdmission + )? + private var activePrimaryAuthenticationReconciliation: ( + loginGenerationID: UUID, + finalResult: LoginFinalResultCompletion + )? + private var pendingRuntimeRestart: ( + admission: CodexReviewRuntimeRestartAdmission, + start: AccountRuntimeTransitionCoordinator.ExplicitRuntimeStart + )? + private var pendingRuntimeAuthDrainTask: Task? + private var pendingRuntimeAuthDrainRequestRevision: UInt64 = 0 + private var runtimeStopFollowups: [UInt64: Task] = [:] private var settingsSnapshot = CodexReviewSettings.Snapshot() private let codexHomeURL: URL private let mcpHTTPServerConfiguration: CodexReviewMCPHTTPServer.Configuration - private let nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? - private let webAuthenticationSessionFactory: CodexReviewNativeAuthentication.WebSessionFactory private let externalURLOpener: ExternalURLOpener private let mcpHTTPServerFactory: MCPHTTPServerFactory? private let mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver private let mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker private let appServerRuntimeFactory: AppServerRuntimeFactory - private let shutdownCleanupTimeout: Duration + private let appServerCloser: CodexReviewAppServerCloser + private let authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? + private let finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? + private let reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? + private let accountRegistry: AccountRegistryStore + private let accountRuntimeTransitionCoordinator: AccountRuntimeTransitionCoordinator + private let registryLoadFailure: CodexReviewAuthenticationFailure? private let appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? private weak var attachedStore: CodexReviewStore? + private var appServer: CodexAppServer? { + runtimeSession?.activeRuntime?.appServer + } + + private var appServerBackend: AppServerCodexReviewBackend? { + runtimeSession?.activeRuntime?.backend + } + + private var teardownAppServerBackend: AppServerCodexReviewBackend? { + runtimeSession?.runtime?.backend + } + + private func quiesceRuntimeAdmissionForAccountTransition() async { + guard let session = runtimeSession else { + return + } + session.closeAdmission() + await accountRegistry.closeRuntimeAdmission(generation: session.generation) + await session.mcpHTTPServer?.stop() + } + + private var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? { + runtimeSession?.activeMCPHTTPServer + } + init( environment: [String: String] = ProcessInfo.processInfo.environment, runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory = CodexReviewNativeAuthentication.WebSessions.system, externalURLOpener: @escaping ExternalURLOpener = defaultExternalURLOpener, mcpHTTPServerFactory: MCPHTTPServerFactory? = { store, configuration, logProjectionProvider in CodexReviewMCPHTTPServer( @@ -256,8 +357,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { }, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? = nil, + accountRegistryLoadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil, + finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? = nil, + finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil, + reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + appServerCloser: @escaping CodexReviewAppServerCloser = { await $0.close() }, appServerRuntimeFactory: AppServerRuntimeFactory? = nil ) { let runtimePreferences = runtimePreferences.normalized @@ -265,36 +375,73 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { runtimePreferences: runtimePreferences, environment: environment ) + accountRegistry = AccountRegistryStore( + codexHomeURL: codexHomeURL, + authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationCancellationDidRequest: authenticationCancellationDidRequest, + authenticationProductCommitDidApply: authenticationProductCommitDidApply, + registryDestinationDidReplace: registryDestinationDidReplace, + loadDidBegin: accountRegistryLoadDidBegin + ) + accountRuntimeTransitionCoordinator = AccountRuntimeTransitionCoordinator( + finalShutdownDidRequest: finalShutdownDidRequest + ) self.mcpHTTPServerConfiguration = .init( host: runtimePreferences.mcpHost, port: runtimePreferences.mcpPort, endpoint: runtimePreferences.mcpPath ) - self.nativeAuthenticationConfiguration = nativeAuthenticationConfiguration - self.webAuthenticationSessionFactory = webAuthenticationSessionFactory self.externalURLOpener = externalURLOpener self.mcpHTTPServerFactory = mcpHTTPServerFactory self.mcpPortOwnerResolver = mcpPortOwnerResolver ?? Self.defaultMCPPortOwnerResolver self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker - self.shutdownCleanupTimeout = shutdownCleanupTimeout self.appServerLifecycleHandler = appServerLifecycleHandler + self.appServerCloser = appServerCloser + self.authenticationHandleDidBind = authenticationHandleDidBind + self.finalRuntimeRetirementDidClaim = finalRuntimeRetirementDidClaim + self.reconciliationDebtDidClear = reconciliationDebtDidClear self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory( codexExecutablePath: runtimePreferences.codexExecutablePath ) - let registry = CodexReviewAccountRegistry.load( - codexHomeURL: codexHomeURL - ) + let registry: AccountRegistryStore.Snapshot + do { + registry = try AccountRegistryStore.loadInitialSnapshot(codexHomeURL: codexHomeURL) + registryLoadFailure = nil + } catch let failure as CodexReviewAuthenticationFailure { + registry = .init(accounts: [], activeAccountKey: nil) + registryLoadFailure = failure + } catch { + registry = .init(accounts: [], activeAccountKey: nil) + registryLoadFailure = .persistenceInconsistent(message: error.localizedDescription) + } seed = CodexReviewStoreSeed( shouldAutoStartEmbeddedServer: true, - initialAccounts: registry.accounts, + initialAccounts: registry.accounts.map(makeCodexReviewAccount(from:)), initialActiveAccountKey: registry.activeAccountKey ) + accountRuntimeTransitionCoordinator.installDidBecomeIdle { [weak self] in + self?.schedulePendingRuntimeAuthDrain() + } } var isActive: Bool { appServer != nil } + var acceptsNewReviewOperations: Bool { + isActive + && runtimeSession?.hasCurrentAccountObservation == true + && accountRuntimeTransitionCoordinator.acceptsNewOperations + } + + var reviewThreadRetentionCodexHomePath: String { + codexHomeURL.path + } + + var reviewThreadRetentionJournalURL: URL? { + codexHomeURL.appendingPathComponent("review-thread-retention.json", isDirectory: false) + } + var invokesRuntimeStopReviewCleanupDuringStop: Bool { true } @@ -430,7 +577,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer: appServer, modelContainer: modelContainer, backend: AppServerCodexReviewBackend( - appServer: appServer, modelContainer: modelContainer ) ) @@ -441,34 +587,210 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { attachedStore = store } + func beginRuntimeRestart() -> CodexReviewRuntimeRestartAdmission? { + guard loginSession == nil, + pendingRuntimeRestart == nil, + let start = accountRuntimeTransitionCoordinator.prepareForExplicitRuntimeStart() else { + return nil + } + let admission = CodexReviewRuntimeRestartAdmission() + pendingRuntimeRestart = (admission, start) + return admission + } + + func resumeRuntimeRestart( + store: CodexReviewStore, + admission: CodexReviewRuntimeRestartAdmission + ) async { + guard let pendingRuntimeRestart, + pendingRuntimeRestart.admission == admission else { + return + } + self.pendingRuntimeRestart = nil + await start( + store: store, + forceRestartIfNeeded: true, + explicitStart: pendingRuntimeRestart.start + ) + } + + func claimRuntimeRestart(_ admission: CodexReviewRuntimeRestartAdmission) -> Bool { + guard let pendingRuntimeRestart, + pendingRuntimeRestart.admission == admission else { + return false + } + guard accountRuntimeTransitionCoordinator.shouldStageExplicitRuntimeStart( + pendingRuntimeRestart.start + ) else { + self.pendingRuntimeRestart = nil + accountRuntimeTransitionCoordinator.finishExplicitRuntimeStart( + pendingRuntimeRestart.start, + didCommitActiveRuntime: false + ) + return false + } + return true + } + func start(store: CodexReviewStore, forceRestartIfNeeded: Bool) async { + guard loginSession == nil, + pendingRuntimeRestart == nil else { + logger.info("Rejecting a Host runtime start while authentication owns the active runtime") + if let session = runtimeSession, session.isActive { + store.transitionToRunning(serverURL: await session.activeMCPHTTPServer?.url) + } else { + store.transitionToFailed("Authentication is already in progress.") + } + return + } + guard let explicitStart = accountRuntimeTransitionCoordinator.prepareForExplicitRuntimeStart() else { + if accountRuntimeTransitionCoordinator.isFinalShutdownRequested == false, + let session = runtimeSession, + session.isActive { + store.transitionToRunning(serverURL: await session.activeMCPHTTPServer?.url) + } else { + store.transitionToFailed("The previous final shutdown has not completed.") + } + return + } + await start( + store: store, + forceRestartIfNeeded: forceRestartIfNeeded, + explicitStart: explicitStart + ) + } + + private func start( + store: CodexReviewStore, + forceRestartIfNeeded: Bool, + explicitStart: AccountRuntimeTransitionCoordinator.ExplicitRuntimeStart + ) async { + let didStart = await startRuntime( + store: store, + forceRestartIfNeeded: forceRestartIfNeeded, + expectedAccount: nil, + mode: .published(owner: .explicit(explicitStart)), + registryAuthorization: nil, + accountSnapshotForPublication: nil + ) + let didCommitActiveRuntime = didStart && runtimeSession?.isActive == true + if didStart == false { + do { + if try await accountRegistry.reconciliationDebtExpectation() != nil { + _ = accountRuntimeTransitionCoordinator.commitExplicitRuntimeStartFailure(explicitStart) + } + } catch { + let failure = (error as? CodexReviewAuthenticationFailure) + ?? .persistenceInconsistent(message: error.localizedDescription) + if accountRuntimeTransitionCoordinator.commitExplicitRuntimeStartFailure(explicitStart) { + store.auth.updatePhase(.failed(failure)) + store.transitionToFailed(failure.localizedDescription) + } + } + } + accountRuntimeTransitionCoordinator.finishExplicitRuntimeStart( + explicitStart, + didCommitActiveRuntime: didCommitActiveRuntime + ) + } + + private func startRuntime( + store: CodexReviewStore, + forceRestartIfNeeded: Bool, + expectedAccount: ExpectedRuntimeAccount?, + mode: RuntimeStartMode, + registryAuthorization: AccountRegistryStore.MutationLease?, + accountSnapshotForPublication: AccountRegistryStore.Snapshot? + ) async -> Bool { logger.info("Starting review runtime; forceRestartIfNeeded=\(forceRestartIfNeeded, privacy: .public)") - if appServerBackend != nil, forceRestartIfNeeded == false { + if let registryLoadFailure { + if shouldPublishRuntimeState(mode: mode) { + store.auth.updatePhase(.failed(registryLoadFailure)) + store.transitionToFailed(registryLoadFailure.localizedDescription) + } + return false + } + if let session = runtimeSession, + session.isActive, + forceRestartIfNeeded == false, + mode.explicitStart.map({ + accountRuntimeTransitionCoordinator.explicitRuntimeStartRequiresRepair($0) + }) != true { + guard claimRuntimePublicationCommit(mode: mode) else { + return false + } logger.info("Review runtime already has an app-server backend") - store.transitionToRunning(serverURL: await mcpHTTPServer?.url) - return + if shouldPublishRuntimeState(mode: mode) { + store.transitionToRunning(serverURL: await mcpHTTPServer?.url) + } + return true + } + if let session = runtimeSession { + if case .stopIncomplete = session.phase { + if shouldPublishRuntimeState(mode: mode) { + store.transitionToFailed( + "Review thread retention recovery is quarantined; the previous runtime cannot be replaced." + ) + } + return false + } + await stop(store: store, purpose: .runtimeRestartPreservingRuns) + guard runtimeSession == nil else { + if shouldPublishRuntimeState(mode: mode) { + store.transitionToFailed("The previous review runtime did not finish stopping.") + } + return false + } } - if forceRestartIfNeeded { - await stop(store: store) + if mode.explicitStart != nil, + shouldStageRuntimePublication(mode: mode) == false { + logger.info("Skipping a superseded runtime start before creating replacement resources") + return false } - var startedAppServer: CodexAppServer? - var startedHTTPServer: (any CodexReviewMCPHTTPServing)? + precondition(nextRuntimeGeneration < .max, "Host runtime generations must not wrap.") + let session = HostRuntimeSession( + generation: nextRuntimeGeneration, + lifecycleHandler: appServerLifecycleHandler, + finalRetirementDidClaim: finalRuntimeRetirementDidClaim + ) + nextRuntimeGeneration += 1 + runtimeSession = session + var clearedDebtExpectation: ExpectedRuntimeAccount? do { - if mcpHTTPServerFactory != nil { - try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration) - } + let persistedDebtExpectation = try await accountRegistry.reconciliationDebtExpectation() + let validationExpectation = expectedAccount ?? persistedDebtExpectation let runtime = try await appServerRuntimeFactory(codexHomeURL) + guard runtimeSession === session else { + await appServerCloser(runtime.appServer) + return false + } + do { + try session.requireHealthyStaging() + } catch { + await appServerCloser(runtime.appServer) + return false + } + session.installRuntime(runtime) let appServer = runtime.appServer let backend = runtime.backend let modelContainer = runtime.modelContainer - startedAppServer = appServer - self.appServer = appServer - self.appServerModelContainer = modelContainer - self.appServerBackend = backend - appServerLifecycleHandler?(modelContainer) - observeAuthNotifications(appServer: appServer, backend: backend, store: store) - if let mcpHTTPServerFactory { + try await installRuntimeConsumers( + session: session, + appServer: appServer, + store: store + ) + switch await store.recoverOrphanedReviewThreads() { + case .recovered, .cleanupIncomplete: + break + case .journalUnavailable(let message): + throw ReviewBackendFailure.retentionJournal(message: message) + } + try requireCurrentStagingSession(session) + if shouldStageRuntimePublication(mode: mode), + let mcpHTTPServerFactory { + try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration) + try requireCurrentStagingSession(session) let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider( modelContext: modelContainer.mainContext ) @@ -477,28 +799,249 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpHTTPServerConfiguration, logProjectionProvider ) - try await mcpHTTPServer.start() - startedHTTPServer = mcpHTTPServer - self.mcpHTTPServer = mcpHTTPServer + session.installMCPHTTPServer(mcpHTTPServer) + try await mcpHTTPServer.stage() + } + try requireCurrentStagingSession(session) + let serverURL = await session.mcpHTTPServer?.url + try requireCurrentStagingSession(session) + var pendingDebtExpectation = persistedDebtExpectation + while true { + let authResolution = try await reconcileStagingRuntimeAuthentication( + session: session, + backend: backend, + validationExpectation: validationExpectation, + registryAuthorization: registryAuthorization, + accountSnapshotForPublication: accountSnapshotForPublication + ) + if let debtExpectation = pendingDebtExpectation { + clearedDebtExpectation = debtExpectation + try await accountRegistry.clearReconciliationDebt() + await reconciliationDebtDidClear?(appServer) + try requireCurrentStagingSession(session) + pendingDebtExpectation = nil + } + try requireCurrentStagingSession(session) + guard session.accountInvalidationRevision == authResolution.revision else { + continue + } + guard mode.requestsPublication else { + session.recordAccountObservation( + authResolution.observation, + revision: authResolution.revision + ) + logger.info("Review runtime staged for final account-transition cleanup") + return true + } + guard shouldStageRuntimePublication(mode: mode) else { + session.recordAccountObservation( + authResolution.observation, + revision: authResolution.revision + ) + logger.info("Review runtime publication was superseded after debt reconciliation") + if mode.explicitStart != nil { + await discardStagingRuntime(session, store: store) + return false + } + return true + } + await accountRegistry.openRuntimeAdmission(generation: session.generation) + do { + try requireCurrentStagingSession(session) + } catch { + await accountRegistry.closeRuntimeAdmission(generation: session.generation) + throw error + } + guard session.accountInvalidationRevision == authResolution.revision else { + await accountRegistry.closeRuntimeAdmission(generation: session.generation) + continue + } + session.recordAccountObservation( + authResolution.observation, + revision: authResolution.revision + ) + guard claimRuntimePublicationCommit(mode: mode) else { + await accountRegistry.closeRuntimeAdmission(generation: session.generation) + logger.info("Review runtime publication was superseded at its commit point") + if mode.explicitStart != nil { + await discardStagingRuntime(session, store: store) + return false + } + return true + } + session.commit() + if shouldPublishRuntimeState(mode: mode), + let reconciledAccountSnapshot = authResolution.persisted { + applyAccountRegistrySnapshot(reconciledAccountSnapshot, to: store.auth) + store.auth.updatePhase(.signedOut) + } + store.transitionToRunning(serverURL: serverURL) + await session.mcpHTTPServer?.activate() + guard runtimeSession === session, session.isActive else { + return false + } + await refreshSelectedAccountRateLimits(auth: store.auth) + logger.info("Review runtime started") + return true } - store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) - let authSnapshot = try await backend.readAuth() - applyAuthSnapshot(authSnapshot, to: store.auth) - await refreshSelectedAccountRateLimits(auth: store.auth) - logger.info("Review runtime started") } catch { + if let clearedDebtExpectation { + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: clearedDebtExpectation, + message: "Runtime validation failed after reconciliation debt was cleared: \(error.localizedDescription)" + ) + } catch { + preconditionFailure( + "A failed debt-repair runtime must durably restore reconciliation debt: \(error.localizedDescription)" + ) + } + } + let ownsStagingFailure = runtimeSession === session && session.isStaging + guard ownsStagingFailure else { + _ = await session.waitForStopCompletion() + logger.debug("Ignoring a late startup result from a stopped or superseded Host runtime generation") + return false + } let failureMessage = await runtimeStartupFailureMessage(for: error) + guard runtimeSession === session, session.isStaging else { + _ = await session.waitForStopCompletion() + logger.debug("Ignoring a late startup failure from a stopped Host runtime generation") + return false + } logger.error("Review runtime failed to start: \(failureMessage, privacy: .public)") - await startedHTTPServer?.stop() - await startedAppServer?.close() - self.appServer = nil - clearAppServerModelContainer() - self.appServerBackend = nil - self.mcpHTTPServer = nil - authNotificationTask?.cancel() - authNotificationTask = nil - store.transitionToFailed(failureMessage) + let stopTask = session.requestStop(purpose: .runtimeRestartPreservingRuns) { session in + await self.accountRegistry.closeRuntimeAdmission(generation: session.generation) + return await self.performRuntimeStop( + session: session, + store: store, + reviewCleanupMode: .connected, + reviewCancellation: .system(message: "Review runtime staging failed."), + loginTerminationReason: .runtimeFailure(.runtime(message: failureMessage)) + ) + } + if let stopTask, await stopTask.value.didReleaseResources == false { + return false + } + if runtimeSession === session { + runtimeSession = nil + if shouldPublishRuntimeState(mode: mode) { + store.transitionToFailed(failureMessage) + } + } + return false + } + } + + private func requireCurrentStagingSession(_ session: HostRuntimeSession) throws { + guard runtimeSession === session else { + throw HostRuntimeConsumerFailure(message: "The Host runtime staging generation was superseded.") + } + try session.requireHealthyStaging() + } + + private func reconcileStagingRuntimeAuthentication( + session: HostRuntimeSession, + backend: AppServerCodexReviewBackend, + validationExpectation: ExpectedRuntimeAccount?, + registryAuthorization: AccountRegistryStore.MutationLease?, + accountSnapshotForPublication: AccountRegistryStore.Snapshot? + ) async throws -> ( + revision: UInt64, + observation: RuntimeAccountObservation, + persisted: AccountRegistryStore.Snapshot? + ) { + while true { + try requireCurrentStagingSession(session) + let revision = session.accountInvalidationRevision + let authSnapshot = try await backend.readAuth() + try requireCurrentStagingSession(session) + guard session.accountInvalidationRevision == revision else { + continue + } + let observation = runtimeAccountObservation(from: authSnapshot) + if let validationExpectation { + try validateRuntimeAccount(authSnapshot, expected: validationExpectation) + } + let persisted: AccountRegistryStore.Snapshot? + if let accountSnapshotForPublication { + persisted = accountSnapshotForPublication + } else if shouldReconcileRuntimeAuthSnapshot( + expectation: validationExpectation, + observation: observation + ) { + persisted = try await reconcileAuthSnapshotSerialized( + authSnapshot, + authorization: registryAuthorization + ).persisted + } else { + persisted = try await accountRegistry.load() + } + try requireCurrentStagingSession(session) + guard session.accountInvalidationRevision == revision else { + continue + } + return (revision, observation, persisted) + } + } + + private func discardStagingRuntime( + _ session: HostRuntimeSession, + store: CodexReviewStore + ) async { + guard runtimeSession === session, session.isStaging else { + return + } + let stopTask = session.requestStop(purpose: .runtimeRestartPreservingRuns) { session in + await self.accountRegistry.closeRuntimeAdmission(generation: session.generation) + return await self.performRuntimeStop( + session: session, + store: store, + reviewCleanupMode: .connected, + reviewCancellation: .system(message: "Review runtime publication was superseded."), + loginTerminationReason: .storeStop + ) + } + let didReleaseResources = if let stopTask { + await stopTask.value.didReleaseResources + } else { + session.phase == .stopped + } + if didReleaseResources, runtimeSession === session { + runtimeSession = nil + } + } + + private func shouldStageRuntimePublication(mode: RuntimeStartMode) -> Bool { + guard case .published(let owner) = mode else { + return false } + switch owner { + case .explicit(let explicitStart): + return accountRuntimeTransitionCoordinator.shouldStageExplicitRuntimeStart(explicitStart) + case .account(let transition): + return accountRuntimeTransitionCoordinator.shouldStageRuntimePublication(transition) + case .primary(let reservation): + return accountRuntimeTransitionCoordinator.shouldStageRuntimePublication(reservation) + } + } + + private func claimRuntimePublicationCommit(mode: RuntimeStartMode) -> Bool { + guard case .published(let owner) = mode else { + return false + } + switch owner { + case .explicit(let explicitStart): + return accountRuntimeTransitionCoordinator.claimExplicitRuntimeStartCommit(explicitStart) + case .account(let transition): + return accountRuntimeTransitionCoordinator.claimRuntimePublication(transition) + case .primary(let reservation): + return accountRuntimeTransitionCoordinator.claimRuntimePublication(reservation) + } + } + + private func shouldPublishRuntimeState(mode: RuntimeStartMode) -> Bool { + mode.requestsPublication && shouldStageRuntimePublication(mode: mode) } private func runtimeStartupFailureMessage(for error: Error) async -> String { @@ -530,61 +1073,291 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { store: CodexReviewStore, appServerBackend: AppServerCodexReviewBackend, reason: ReviewCancellation, - timeoutWarning: String + mode: RuntimeReviewCleanupMode ) async { - let shutdownCleanupTimeout = shutdownCleanupTimeout - let cleanupResult = await store.cleanupActiveReviewsForRuntimeStop( - reason: reason, - workerDrainTimeout: shutdownCleanupTimeout - ) { request in - await runRuntimeShutdownCleanup(timeout: shutdownCleanupTimeout) { + let cleanupResult = await store.cleanupActiveReviewsForRuntimeStop(reason: reason) { request in + switch mode { + case .connected: await appServerBackend.cleanupActiveReviewsForShutdown(request) + return true + case .connectionTerminated: + await appServerBackend.cleanupActiveReviewsAfterConnectionTermination(request) + return true } } - if cleanupResult.didComplete == false { - logger.warning("\(timeoutWarning, privacy: .public)") + precondition(cleanupResult.didComplete, "Runtime teardown must drain backend cleanup and review workers.") + } + + func stop(store: CodexReviewStore, purpose: CodexReviewRuntimeStopPurpose) async { + if purpose.retiresRuns { + _ = await accountRuntimeTransitionCoordinator.performFinalShutdown { [weak self, weak store] in + guard let self, let store else { + return true + } + await self.stopRuntime(store: store, purpose: purpose) + return self.runtimeSession == nil + } + return } + await stopRuntime(store: store, purpose: purpose) } - func stop(store: CodexReviewStore) async { - let appServer = appServer - let appServerBackend = appServerBackend - let mcpHTTPServer = mcpHTTPServer - let hasRuntimeState = appServer != nil || appServerBackend != nil || mcpHTTPServer != nil - let loginCleanup = takeLoginRuntimeForCleanup() - guard hasRuntimeState || loginCleanup.isEmpty == false else { + private func stopRuntime( + store: CodexReviewStore, + purpose: CodexReviewRuntimeStopPurpose + ) async { + let session = runtimeSession + let loginSession = self.loginSession + guard let session else { + _ = await loginSession?.terminate(reason: .storeStop) + if purpose.retiresRuns, + store.reviewRuns.isEmpty == false, + await startRuntime( + store: store, + forceRestartIfNeeded: true, + expectedAccount: nil, + mode: .quiescentReconciliation, + registryAuthorization: nil, + accountSnapshotForPublication: nil + ) { + await stopRuntime(store: store, purpose: purpose) + return + } + if purpose.retiresRuns { + _ = await store.retireReviewRunsForFinalStoreStop() + } return } logger.info("Stopping review runtime") - if let appServerBackend { - let reason = ReviewCancellation.system(message: "Review runtime stopped.") - await cleanupActiveReviewsForRuntimeTeardown( + session.closeAdmission() + let task = session.requestStop(purpose: purpose) { session in + await self.accountRegistry.closeRuntimeAdmission(generation: session.generation) + return await self.performRuntimeStop( + session: session, store: store, - appServerBackend: appServerBackend, - reason: reason, - timeoutWarning: "Timed out cleaning active reviews before stopping runtime" - ) - } - self.appServer = nil - clearAppServerModelContainer() - self.mcpHTTPServer = nil - authNotificationTask?.cancel() - authNotificationTask = nil - await mcpHTTPServer?.stop() - self.appServerBackend = nil - await cleanupLoginRuntime(loginCleanup) - await appServer?.close() - logger.info("Review runtime stopped") - } - - func waitUntilStopped() async {} - - func refreshSettings() async throws -> CodexReviewSettings.Snapshot { - guard let appServerBackend else { - return settingsSnapshot + reviewCleanupMode: .connected, + reviewCancellation: .system(message: "Review runtime stopped."), + loginTerminationReason: .storeStop + ) + } + if let task { + await installRuntimeStopFollowup( + stopTask: task, + session: session, + store: store + ).value + await session.waitForFinalRetirementClaim() + } else if let runtimeStopFollowup = runtimeStopFollowups[session.generation] { + await runtimeStopFollowup.value + await session.waitForFinalRetirementClaim() } - settingsSnapshot = try await Self.monitorSettings(from: appServerBackend.readSettings()) - return settingsSnapshot + } + + private func installRuntimeStopFollowup( + stopTask: Task, + session: HostRuntimeSession, + store: CodexReviewStore + ) -> Task { + if let runtimeStopFollowup = runtimeStopFollowups[session.generation] { + return runtimeStopFollowup + } + let generation = session.generation + let followupTask = Task { @MainActor [weak self, weak store] in + let result = await stopTask.value + await session.waitForFinalRetirementClaim() + guard let self else { + return + } + defer { self.runtimeStopFollowups.removeValue(forKey: generation) } + guard let store else { + return + } + if result.didReleaseResources, self.runtimeSession === session { + self.runtimeSession = nil + } + let handoff = result.didReleaseResources + ? session.takePrimaryAuthenticationHandoff(from: result) + : nil + guard result.didReleaseResources else { + return + } + await self.consumePrimaryAuthenticationHandoff( + handoff, + stoppedSession: session, + store: store + ) + if handoff == nil, + result.didRetireRuns == false, + self.accountRuntimeTransitionCoordinator.isFinalShutdownRequested, + await self.startRuntime( + store: store, + forceRestartIfNeeded: true, + expectedAccount: nil, + mode: .quiescentReconciliation, + registryAuthorization: nil, + accountSnapshotForPublication: nil + ) { + await self.stopRuntime( + store: store, + purpose: .finalStoreShutdownRetiringRuns + ) + } + } + runtimeStopFollowups[generation] = followupTask + return followupTask + } + + private func consumePrimaryAuthenticationHandoff( + _ handoff: PrimaryAuthenticationReconciliationHandoff?, + stoppedSession: HostRuntimeSession, + store: CodexReviewStore + ) async { + guard let handoff else { + return + } + if loginSession?.generationID == handoff.loginGenerationID { + loginSession = nil + } + precondition( + runtimeSession !== stoppedSession, + "A primary authentication handoff can start replacement work only after the old runtime is detached." + ) + await accountRuntimeTransitionCoordinator.performStoppedPrimaryReconciliation { + [weak self, weak store] reservation in + guard let self, let store else { + return + } + await self.performPrimaryAuthenticationReconciliation( + handoff, + reservation: reservation, + auth: store.auth, + oldRuntimeAlreadyStopped: true + ) + } + if accountRuntimeTransitionCoordinator.isFinalShutdownRequested, + runtimeSession != nil { + await stopRuntime( + store: store, + purpose: .finalStoreShutdownRetiringRuns + ) + } + } + + private func performRuntimeStop( + session: HostRuntimeSession, + store: CodexReviewStore, + reviewCleanupMode: RuntimeReviewCleanupMode, + reviewCancellation: ReviewCancellation, + loginTerminationReason: LoginTerminationReason + ) async -> HostRuntimeStopResult { + let runtime = session.runtime + await session.mcpHTTPServer?.stop() + var didRetainPreparedRestarts = true + if let appServerBackend = runtime?.backend { + let retainedAttemptsByRunID = await appServerBackend.discardAllPreparedReviewRestarts( + ownedAttemptsByRunID: store.runtimeStopReviewAttemptOwners() + ) + didRetainPreparedRestarts = await store.retainPreparedRestartAttemptsForRuntimeStop( + retainedAttemptsByRunID + ) + await cleanupActiveReviewsForRuntimeTeardown( + store: store, + appServerBackend: appServerBackend, + reason: reviewCancellation, + mode: reviewCleanupMode + ) + } + let stoppingLoginSession = loginSession + await stoppingLoginSession?.recordCancellationIntent() + if let stoppingLoginSession, + let lease = stoppingLoginSession.mutationLeaseForCancellation() { + await accountRegistry.requestAuthenticationCancellation(lease) + } + let loginTerminal = await stoppingLoginSession?.terminate(reason: loginTerminationReason) + let primaryAuthenticationHandoff = loginTerminal.flatMap { terminal in + stoppingLoginSession?.takePrimaryAuthenticationHandoffForRuntimeStop(from: terminal) + } + if let primaryAuthenticationHandoff { + installActivePrimaryAuthenticationReconciliation(primaryAuthenticationHandoff) + if let stoppingLoginSession { + clearLoginSessionIfCurrent(stoppingLoginSession) + } + } + session.retainPrimaryAuthenticationHandoffForStop(primaryAuthenticationHandoff) + guard didRetainPreparedRestarts else { + logger.error("Review runtime remains open because prepared-restart cleanup ownership could not be persisted") + return .init( + didReleaseResources: false, + didRetireRuns: false, + primaryAuthenticationHandoff: primaryAuthenticationHandoff + ) + } + var didRetireRuns = false + if session.shouldRetireRuns + || accountRuntimeTransitionCoordinator.isFinalShutdownRequested { + guard await store.retireReviewRunsForFinalStoreStop() else { + logger.error("Review runtime remains open because an unpersisted cleanup quarantine is unresolved") + return .init( + didReleaseResources: false, + didRetireRuns: false, + primaryAuthenticationHandoff: primaryAuthenticationHandoff + ) + } + didRetireRuns = true + } + await session.cancelConsumersAndWait() + if didRetireRuns == false, + session.shouldRetireRuns + || accountRuntimeTransitionCoordinator.isFinalShutdownRequested { + guard await store.retireReviewRunsForFinalStoreStop() else { + logger.error("Review runtime remains open because a late final shutdown upgrade could not retire its runs") + return .init( + didReleaseResources: false, + didRetireRuns: false, + primaryAuthenticationHandoff: primaryAuthenticationHandoff + ) + } + didRetireRuns = true + } + if let appServer = runtime?.appServer { + await appServerCloser(appServer) + } + logger.info("Review runtime stopped") + return .init( + didReleaseResources: true, + didRetireRuns: didRetireRuns, + primaryAuthenticationHandoff: primaryAuthenticationHandoff + ) + } + + func waitUntilStopped() async { + await accountRuntimeTransitionCoordinator.waitForFinalShutdownCompletionIfRequested() + while true { + if let task = runtimeSession?.stopTask { + _ = await task.value + continue + } + let followups = Array(runtimeStopFollowups.values) + if followups.isEmpty == false { + for task in followups { + await task.value + } + continue + } + if let pendingRuntimeAuthDrainTask { + await pendingRuntimeAuthDrainTask.value + continue + } + return + } + } + + func refreshSettings() async throws -> CodexReviewSettings.Snapshot { + let admitted = try requireAdmittedRuntimeBackend() + let refreshed = try await Self.monitorSettings(from: admitted.backend.readSettings()) + try requireRuntimeCommitAdmission(generation: admitted.generation) + settingsSnapshot = refreshed + return settingsSnapshot } func updateSettingsModel( @@ -594,9 +1367,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { serviceTier: CodexReviewSettings.ServiceTier?, persistServiceTier: Bool ) async throws { - guard let appServerBackend else { - return - } + let admitted = try requireAdmittedRuntimeBackend() var change = CodexReviewBackendModel.Settings.Change( model: model, updatesModel: true @@ -609,156 +1380,280 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { change.serviceTier = serviceTier?.rawValue change.updatesServiceTier = true } - settingsSnapshot = try await Self.monitorSettings(from: appServerBackend.applySettings(change)) + let updated = try await Self.monitorSettings(from: admitted.backend.applySettings(change)) + try requireRuntimeCommitAdmission(generation: admitted.generation) + settingsSnapshot = updated } func updateSettingsReasoningEffort( _ reasoningEffort: CodexReviewSettings.ReasoningEffort? ) async throws { - guard let appServerBackend else { - return - } - settingsSnapshot = try await Self.monitorSettings( - from: appServerBackend.applySettings(.init( + let admitted = try requireAdmittedRuntimeBackend() + let updated = try await Self.monitorSettings( + from: admitted.backend.applySettings(.init( reasoningEffort: reasoningEffort?.rawValue, updatesReasoningEffort: true )) ) + try requireRuntimeCommitAdmission(generation: admitted.generation) + settingsSnapshot = updated } func updateSettingsServiceTier( _ serviceTier: CodexReviewSettings.ServiceTier? ) async throws { - guard let appServerBackend else { - return - } - settingsSnapshot = try await Self.monitorSettings( - from: appServerBackend.applySettings(.init( + let admitted = try requireAdmittedRuntimeBackend() + let updated = try await Self.monitorSettings( + from: admitted.backend.applySettings(.init( serviceTier: serviceTier?.rawValue, updatesServiceTier: true )) ) + try requireRuntimeCommitAdmission(generation: admitted.generation) + settingsSnapshot = updated } func refreshAuth(auth: CodexReviewAuthModel) async { - do { - guard let appServerBackend else { - auth.updatePhase(.signedOut) - return - } - let snapshot = try await appServerBackend.readAuth() - applyAuthSnapshot(snapshot, to: auth) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + guard loginSession == nil, acceptsNewReviewOperations else { + logger.debug("Dropping an authentication refresh while runtime admission is closed") + return } + guard let session = runtimeSession, + let appServerBackend = session.activeRuntime?.backend else { + auth.updatePhase(.signedOut) + return + } + guard let reservation = accountRuntimeTransitionCoordinator.reserveRuntimeAuthReconciliation( + generation: session.generation + ) else { + logger.debug("Dropping an authentication refresh while runtime admission is closed") + return + } + _ = await performRuntimeAuthReconciliation( + reservation: reservation, + generation: session.generation, + backend: appServerBackend, + auth: auth, + cause: .manualRefresh + ) } - func signIn(auth: CodexReviewAuthModel) async { - await startLogin(auth: auth, activation: .activateAuthenticatedAccount) + func signIn(auth: CodexReviewAuthModel) async throws { + try await beginStockLogin(auth: auth, request: .signIn) } - func addAccount(auth: CodexReviewAuthModel) async { - let activeAccountKey = auth.persistedActiveAccountKey ?? auth.selectedAccount?.accountKey - await startLogin( - auth: auth, - activation: activeAccountKey != nil - ? .preserveActiveAccount(activeAccountKey) - : .activateAuthenticatedAccount - ) + func addAccount(auth: CodexReviewAuthModel) async throws { + try await beginStockLogin(auth: auth, request: .addAccount) } - func cancelAuthentication(auth: CodexReviewAuthModel) async { - let activeAuthenticationSession = activeAuthenticationSession - self.activeAuthenticationSession = nil - authenticationTask?.cancel() - authenticationTask = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - let loginBackend = loginBackend - self.loginBackend = nil - isWaitingForLoginAccountUpdate = false - let loginAppServer = loginAppServer - self.loginAppServer = nil - let loginCodexHomeURL = loginCodexHomeURL - self.loginCodexHomeURL = nil - defer { - loginChallenge = nil - } - await activeAuthenticationSession?.cancel() - guard let loginBackend, let loginChallenge else { - if auth.selectedAccount == nil { - auth.updatePhase(.signedOut) + func cancelAuthentication(auth _: CodexReviewAuthModel) async { + guard let session = loginSession else { + if let activePrimaryAuthenticationReconciliation { + _ = await activePrimaryAuthenticationReconciliation.finalResult.wait() } - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) return } - do { - try await loginBackend.cancelLogin(loginChallenge) - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + await session.recordCancellationIntent() + if let lease = session.mutationLeaseForCancellation() { + await accountRegistry.requestAuthenticationCancellation(lease) + } + let terminal = await session.terminate(reason: .explicitCancellation) + if case .primaryRuntimeReconciliation = terminal { + _ = await session.waitForPrimaryAuthenticationFinalResult() } - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) } func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { - guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { + try await attachedStore?.requireReviewThreadRetentionAcceptance() + try await withAccountMutation { transition, mutation in + let before = mutation.before + guard let attachedStore, appServerBackend != nil else { + let prepared = try await accountRegistry.prepareAccountActivation(accountKey) + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + _ = try await accountRegistry.abortPreparedMutation(prepared) + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + mutation.expectRecovery(.account(accountKey)) + let persisted = try await commitPreparedAccountMutation( + prepared, + expectedAccount: .account(accountKey), + transition: transition + ) + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + auth.updatePhase(.signedOut) + } return } - try CodexReviewAccountRegistry.activateAccount( - accountKey, - accounts: auth.persistedAccounts, - codexHomeURL: codexHomeURL + await quiesceRuntimeAdmissionForAccountTransition() + await attachedStore.closeActiveReviewSessions( + reason: .system(message: "Account switched.") ) - auth.applyPersistedAccountStates( - auth.persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: accountKey + let prepared: AccountRegistryStore.PreparedMutation + do { + prepared = try await accountRegistry.prepareAccountActivation(accountKey) + } catch { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: attachedStore, + transition: transition, + originalError: error + ) + } + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + _ = try await accountRegistry.abortPreparedMutation(prepared) + try await recoverQuiescedPreEffectRuntime( + before: before, + store: attachedStore, + transition: transition, + originalError: CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + ) + } + await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) + mutation.expectRecovery(.account(accountKey)) + let persisted = try await commitPreparedAccountMutation( + prepared, + expectedAccount: .account(accountKey), + transition: transition + ) + try await finishCommittedAccountTransition( + expectedAccount: .account(accountKey), + persisted: persisted, + transition: transition, + auth: auth, + store: attachedStore ) - auth.selectPersistedAccount(auth.persistedAccounts.first(where: { $0.accountKey == accountKey })?.id) - auth.updatePhase(.signedOut) - guard let attachedStore, appServerBackend != nil else { - return } - await attachedStore.closeActiveReviewSessions(reason: .system(message: "Account switched.")) - await stop(store: attachedStore) - await start(store: attachedStore, forceRestartIfNeeded: true) } func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { - let removedActiveAccount = auth.selectedAccount?.accountKey == accountKey - || auth.persistedActiveAccountKey == accountKey - if removedActiveAccount, let appServerBackend { - _ = try? await appServerBackend.logout(.init(accountKey)) - } - let remaining = auth.persistedAccounts.filter { $0.accountKey != accountKey } - let activeAccountKey = auth.persistedActiveAccountKey == accountKey - ? nil - : auth.persistedActiveAccountKey - try CodexReviewAccountRegistry.saveAccounts( - remaining, - activeAccountKey: activeAccountKey, - codexHomeURL: codexHomeURL - ) - try CodexReviewAccountRegistry.removeSavedAccountDirectory( - accountKey: accountKey, - codexHomeURL: codexHomeURL - ) + try await attachedStore?.requireReviewThreadRetentionAcceptance() + try await withAccountMutation { transition, mutation in + let before = mutation.before + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + guard before.accounts.contains(where: { $0.accountKey == normalizedAccountKey }) else { + return + } + let removedActiveAccount = before.activeAccountKey == normalizedAccountKey + let transitionBackend = removedActiveAccount ? teardownAppServerBackend : nil + let transitionStore = removedActiveAccount + ? attachedStore.flatMap { transitionBackend == nil ? nil : $0 } + : nil + let persisted: AccountRegistryStore.Snapshot if removedActiveAccount { - try? CodexReviewAccountRegistry.removeSharedAuth(codexHomeURL: codexHomeURL) + if let transitionStore { + await quiesceRuntimeAdmissionForAccountTransition() + await transitionStore.closeActiveReviewSessions( + reason: .system(message: "Account removed.") + ) + } + let prepared: AccountRegistryStore.PreparedMutation + do { + prepared = try await accountRegistry.prepareIrreversibleRemoval( + accountKey: normalizedAccountKey + ) + } catch { + if let transitionStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: transitionStore, + transition: transition, + originalError: error + ) + } + throw error + } + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + _ = try await accountRegistry.abortPreparedMutation(prepared) + if let transitionStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: transitionStore, + transition: transition, + originalError: CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + ) + } + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + var forwardRecoveredSnapshot: AccountRegistryStore.Snapshot? + do { + if let transitionBackend { + _ = try await transitionBackend.logout(.init(normalizedAccountKey)) + } + } catch { + let originalError = error + mutation.expectRecovery(.reconcileCurrentRuntime) + let disposition = try await abortPreparedMutationBeforeEffect( + prepared, + after: originalError + ) + switch disposition { + case .restoredBefore: + mutation.expectBeforeRecovery() + accountRuntimeTransitionCoordinator.recordEffectAborted(transition) + if let transitionStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: transitionStore, + transition: transition, + originalError: originalError + ) + } + throw originalError + case .forwardedDesired(let snapshot): + mutation.expectRecovery(.signedOut) + forwardRecoveredSnapshot = snapshot + } + } + mutation.expectRecovery(.signedOut) + accountRuntimeTransitionCoordinator.recordEffectApplied(transition) + if let transitionStore { + await stop( + store: transitionStore, + purpose: .accountTransitionPreservingRuns + ) + } + if let forwardRecoveredSnapshot { + persisted = forwardRecoveredSnapshot + } else { + persisted = try await commitPreparedAccountMutation( + prepared, + expectedAccount: .signedOut, + transition: transition + ) + } + } else { + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + persisted = try await accountRegistry.removeInactiveAccount( + accountKey: normalizedAccountKey + ) } - auth.applyPersistedAccountStates( - remaining.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) + await accountRegistry.cleanupRemovedAccountDirectory(accountKey: normalizedAccountKey) if removedActiveAccount { - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - guard let attachedStore, appServerBackend != nil else { + guard let transitionStore else { + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + auth.updatePhase(.signedOut) + } return } - await attachedStore.closeActiveReviewSessions(reason: .system(message: "Account removed.")) - await stop(store: attachedStore) - await start(store: attachedStore, forceRestartIfNeeded: true) + try await finishCommittedAccountTransition( + expectedAccount: .signedOut, + persisted: persisted, + transition: transition, + auth: auth, + store: transitionStore + ) + } else { + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + } + } } } @@ -767,58 +1662,135 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { accountKey: String, toIndex: Int ) async throws { - var accounts = auth.persistedAccounts - guard let sourceIndex = accounts.firstIndex(where: { $0.accountKey == accountKey }) else { - return + try await withAccountMutation { transition, _ in + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } - let destinationIndex = max(0, min(toIndex, accounts.count - 1)) - guard sourceIndex != destinationIndex else { - return - } - let account = accounts.remove(at: sourceIndex) - accounts.insert(account, at: destinationIndex) - try CodexReviewAccountRegistry.saveAccounts( - accounts, - activeAccountKey: auth.persistedActiveAccountKey, - codexHomeURL: codexHomeURL + let persisted = try await accountRegistry.reorderAccount( + accountKey: accountKey, + toIndex: toIndex ) - auth.applyPersistedAccountStates(accounts.map(savedAccountPayload(from:))) + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + } + } } func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { - guard let account = auth.selectedAccount else { - auth.updatePhase(.signedOut) - auth.selectPersistedAccount(nil) + try await attachedStore?.requireReviewThreadRetentionAcceptance() + try await withAccountMutation { transition, mutation in + let before = mutation.before + guard let accountKey = before.activeAccountKey else { return } - let shouldRecycleRuntime = attachedStore != nil && appServerBackend != nil - if shouldRecycleRuntime { - await attachedStore?.closeActiveReviewSessions(reason: .system(message: "Signed out.")) + let transitionBackend = teardownAppServerBackend + let shouldRecycleRuntime = attachedStore != nil && transitionBackend != nil + if shouldRecycleRuntime, let attachedStore { + await quiesceRuntimeAdmissionForAccountTransition() + await attachedStore.closeActiveReviewSessions( + reason: .system(message: "Signed out.") + ) + } + let prepared: AccountRegistryStore.PreparedMutation + do { + prepared = try await accountRegistry.prepareIrreversibleRemoval( + accountKey: accountKey + ) + } catch { + if shouldRecycleRuntime, let attachedStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: attachedStore, + transition: transition, + originalError: error + ) + } + throw error } - if let appServerBackend { - _ = try await appServerBackend.logout(.init(account.accountKey)) + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + _ = try await accountRegistry.abortPreparedMutation(prepared) + if shouldRecycleRuntime, let attachedStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: attachedStore, + transition: transition, + originalError: CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + ) + } + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } - let remaining = auth.persistedAccounts.filter { $0.accountKey != account.accountKey } - try CodexReviewAccountRegistry.saveAccounts( - remaining, - activeAccountKey: nil, - codexHomeURL: codexHomeURL - ) - try CodexReviewAccountRegistry.removeSavedAccountDirectory( - accountKey: account.accountKey, - codexHomeURL: codexHomeURL - ) - try? CodexReviewAccountRegistry.removeSharedAuth(codexHomeURL: codexHomeURL) - auth.updatePhase(.signedOut) - auth.selectPersistedAccount(nil) - auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:)), activeAccountKey: nil) + var forwardRecoveredSnapshot: AccountRegistryStore.Snapshot? + do { + if let transitionBackend { + _ = try await transitionBackend.logout(.init(accountKey)) + } + } catch { + let originalError = error + mutation.expectRecovery(.reconcileCurrentRuntime) + let disposition = try await abortPreparedMutationBeforeEffect( + prepared, + after: originalError + ) + switch disposition { + case .restoredBefore: + mutation.expectBeforeRecovery() + accountRuntimeTransitionCoordinator.recordEffectAborted(transition) + if shouldRecycleRuntime, let attachedStore { + try await recoverQuiescedPreEffectRuntime( + before: before, + store: attachedStore, + transition: transition, + originalError: originalError + ) + } + throw originalError + case .forwardedDesired(let snapshot): + mutation.expectRecovery(.signedOut) + forwardRecoveredSnapshot = snapshot + } + } + mutation.expectRecovery(.signedOut) + accountRuntimeTransitionCoordinator.recordEffectApplied(transition) if shouldRecycleRuntime, let attachedStore { - await stop(store: attachedStore) - await start(store: attachedStore, forceRestartIfNeeded: true) + await stop( + store: attachedStore, + purpose: .accountTransitionPreservingRuns + ) + } + let persisted: AccountRegistryStore.Snapshot + if let forwardRecoveredSnapshot { + persisted = forwardRecoveredSnapshot + } else { + persisted = try await commitPreparedAccountMutation( + prepared, + expectedAccount: .signedOut, + transition: transition + ) + } + await accountRegistry.cleanupRemovedAccountDirectory(accountKey: accountKey) + if shouldRecycleRuntime, let attachedStore { + try await finishCommittedAccountTransition( + expectedAccount: .signedOut, + persisted: persisted, + transition: transition, + auth: auth, + store: attachedStore + ) + } else { + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + auth.updatePhase(.signedOut) + } + } } } func refreshAccountRateLimits(auth: CodexReviewAuthModel, accountKey: String) async { + guard acceptsNewReviewOperations else { + return + } guard let account = auth.accounts.first(where: { $0.accountKey == accountKey }) else { return } @@ -829,185 +1801,1054 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { false } - private func startLogin(auth: CodexReviewAuthModel, activation: LoginActivation) async { - var isolatedLoginAppServer: CodexAppServer? - var isolatedLoginCodexHomeURL: URL? + private func withAccountMutation( + _ operation: ( + AccountRuntimeTransitionCoordinator.AccountTransition, + AccountMutationContext + ) async throws -> T + ) async throws -> T { + guard runtimeSession == nil + || runtimeSession?.hasCurrentAccountObservation == true else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + return try await accountRuntimeTransitionCoordinator.perform { transition in + let admittedRuntimeGeneration = runtimeSession?.generation + let admittedRuntimeInvalidationRevision = runtimeSession?.accountInvalidationRevision + let mutation = AccountMutationContext( + try await accountRegistry.beginAccountMutation(), + admittedRuntimeGeneration: admittedRuntimeGeneration, + admittedRuntimeInvalidationRevision: admittedRuntimeInvalidationRevision + ) + do { + let result = try await operation(transition, mutation) + await accountRegistry.finishMutation(mutation.lease) + return result + } catch { + if let failure = error as? CodexReviewAuthenticationFailure, + case .persistenceInconsistent = failure { + await recordUnconfirmedDirectRegistryMutation( + failure, + expectedAccount: mutation.expectationForUnconfirmedMutation( + currentRuntimeSession: runtimeSession + ), + transition: transition + ) + } + await accountRegistry.finishMutation(mutation.lease) + throw error + } + } + } + + private func recordUnconfirmedDirectRegistryMutation( + _ failure: CodexReviewAuthenticationFailure, + expectedAccount: ExpectedRuntimeAccount, + transition: AccountRuntimeTransitionCoordinator.AccountTransition + ) async { + let message = "The account registry mutation has an unresolved durable outcome: " + + failure.localizedDescription do { - let runtime = try await loginRuntime(for: activation) - let appServerBackend = runtime.backend - let loginCodexHomeURL = runtime.codexHomeURL - let loginAppServer = runtime.usesPrimaryRuntime ? nil : runtime.appServer - isolatedLoginAppServer = loginAppServer - isolatedLoginCodexHomeURL = loginCodexHomeURL - guard runtime.usesPrimaryRuntime || self.appServerBackend != nil else { - logger.error("Cannot start login because review runtime is not running") - updateAuthenticationFailure( - "Review runtime is not running.", - auth: auth, - activation: activation + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message + ) + } catch { + preconditionFailure( + "An unresolved account registry mutation must durably record reconciliation debt: \(error.localizedDescription)" + ) + } + if accountRuntimeTransitionCoordinator.commitAccountReconciliationFailure(transition) { + attachedStore?.transitionToFailed(message) + attachedStore?.auth.updatePhase(.failed(.accountCommit(message: message))) + } + } + + private func finishCommittedAccountTransition( + expectedAccount: ExpectedRuntimeAccount, + persisted: AccountRegistryStore.Snapshot, + transition: AccountRuntimeTransitionCoordinator.AccountTransition, + auth: CodexReviewAuthModel, + store: CodexReviewStore + ) async throws { + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + let publication = accountRuntimeTransitionCoordinator.claimPublication(transition) + let didStart = await startRuntime( + store: store, + forceRestartIfNeeded: true, + expectedAccount: expectedAccount, + mode: publication == .published + ? .published(owner: .account(transition)) + : .quiescentReconciliation, + registryAuthorization: nil, + accountSnapshotForPublication: publication == .published ? persisted : nil + ) + guard didStart else { + let message = "The committed account transition could not start and validate its replacement runtime." + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message + ) + } catch { + preconditionFailure( + "A committed account transition must durably record reconciliation debt: \(error.localizedDescription)" ) - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - return } - logger.info("Starting ChatGPT login") - let challenge = try await appServerBackend.startLogin(.init( - nativeWebAuthenticationCallbackScheme: nativeAuthenticationConfiguration?.callbackScheme - )) - loginChallenge = challenge - loginBackend = appServerBackend - self.loginAppServer = loginAppServer - self.loginCodexHomeURL = loginCodexHomeURL - loginActivation = activation - isWaitingForLoginAccountUpdate = false - if let loginAppServer { - observeLoginNotifications(appServer: loginAppServer, backend: appServerBackend, auth: auth) - } - logger.info("Received ChatGPT login challenge") - let nativeCallbackScheme = challenge.nativeWebAuthenticationCallbackScheme - let usesNativeAuthentication = nativeAuthenticationConfiguration != nil - && challenge.verificationURL != nil - auth.updatePhase(.signingIn(.init( - title: "Sign in to Codex", - detail: challenge.signInDetail(nativeAuthentication: usesNativeAuthentication), - browserURL: challenge.verificationURL?.absoluteString, - userCode: challenge.userCode - ))) - guard let nativeAuthenticationConfiguration, challenge.verificationURL != nil else { - if let verificationURL = challenge.verificationURL { - externalURLOpener(verificationURL) - } - return + if accountRuntimeTransitionCoordinator.commitAccountReconciliationFailure(transition) { + store.transitionToFailed(message) + } + throw CodexReviewAuthenticationFailure.accountCommit(message: message) + } + } + + private func commitPreparedAccountMutation( + _ mutation: AccountRegistryStore.PreparedMutation, + expectedAccount: ExpectedRuntimeAccount, + transition: AccountRuntimeTransitionCoordinator.AccountTransition + ) async throws -> AccountRegistryStore.Snapshot { + do { + return try await accountRegistry.commitPreparedMutation(mutation) + } catch { + let message = "The account mutation effect was accepted, but its durable desired state could not be confirmed: " + + error.localizedDescription + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message + ) + } catch { + preconditionFailure( + "An unconfirmed account mutation must durably record reconciliation debt: \(error.localizedDescription)" + ) + } + if accountRuntimeTransitionCoordinator.commitAccountReconciliationFailure(transition) { + attachedStore?.transitionToFailed(message) } - let authURL = try Self.authenticationURL(from: challenge) - let callbackScheme = nativeCallbackScheme ?? nativeAuthenticationConfiguration.callbackScheme - guard callbackScheme == nativeAuthenticationConfiguration.callbackScheme else { - try? await appServerBackend.cancelLogin(challenge) - loginChallenge = nil - loginBackend = nil - self.loginAppServer = nil - self.loginCodexHomeURL = nil - updateAuthenticationFailure( - "Authentication callback is misconfigured.", - auth: auth, - activation: activation + throw CodexReviewAuthenticationFailure.accountCommit(message: message) + } + } + + private func recoverQuiescedPreEffectRuntime( + before: AccountRegistryStore.Snapshot, + store: CodexReviewStore, + transition: AccountRuntimeTransitionCoordinator.AccountTransition, + originalError: any Error + ) async throws -> Never { + await stop(store: store, purpose: .accountTransitionPreservingRuns) + let expectation = before.expectedRuntimeAccount + let publication = accountRuntimeTransitionCoordinator.claimPreEffectRecovery(transition) + let didStart = await startRuntime( + store: store, + forceRestartIfNeeded: true, + expectedAccount: expectation, + mode: publication == .published + ? .published(owner: .account(transition)) + : .quiescentReconciliation, + registryAuthorization: nil, + accountSnapshotForPublication: publication == .published ? before : nil + ) + guard didStart else { + let message = "The account transition failed before its external effect, and the previous runtime could not be restored: " + + originalError.localizedDescription + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectation, + message: message ) - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - return + } catch { + preconditionFailure( + "A failed pre-effect runtime recovery must durably record reconciliation debt: \(error.localizedDescription)" + ) + } + if accountRuntimeTransitionCoordinator.commitAccountReconciliationFailure(transition) { + store.transitionToFailed(message) + } + throw CodexReviewAuthenticationFailure.accountCommit(message: message) + } + throw originalError + } + + private func abortPreparedMutationBeforeEffect( + _ mutation: AccountRegistryStore.PreparedMutation, + after originalError: any Error + ) async throws -> AccountRegistryStore.PreparedAbortDisposition { + do { + return try await accountRegistry.abortPreparedMutation(mutation) + } catch let failure as CodexReviewAuthenticationFailure { + if case .persistenceInconsistent = failure { + throw failure + } + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Account mutation failed and its durable journal could not be reconciled. " + + "Original failure: \(originalError.localizedDescription). " + + "Recovery failure: \(failure.localizedDescription)" + ) + } catch { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Account mutation failed and its durable journal could not be reconciled. " + + "Original failure: \(originalError.localizedDescription). " + + "Recovery failure: \(error.localizedDescription)" + ) + } + } + + private func beginStockLogin( + auth: CodexReviewAuthModel, + request: LoginRequest + ) async throws { + guard loginSession == nil else { + throw CodexReviewAuthenticationFailure.alreadyInProgress + } + guard accountRuntimeTransitionCoordinator.hasActiveLoginTransition == false else { + throw CodexReviewAuthenticationFailure.alreadyInProgress + } + try requireOperationAdmission() + let loginAdmission = try accountRuntimeTransitionCoordinator.reserveLoginAdmission() + let authenticationMutation: AccountRegistryStore.AuthenticationMutation + do { + try await attachedStore?.requireReviewThreadRetentionAcceptance() + guard accountRuntimeTransitionCoordinator.canCommitLoginAdmission(loginAdmission) else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } - let session = try await webAuthenticationSessionFactory( - authURL, - callbackScheme, - nativeAuthenticationConfiguration.browserSessionPolicy, - nativeAuthenticationConfiguration.presentationAnchorProvider + authenticationMutation = try await accountRegistry.beginAuthenticationMutation( + request: request ) - activeAuthenticationSession = session - authenticationTask = Task { @MainActor [weak self, weak auth] in + } catch { + accountRuntimeTransitionCoordinator.finishLoginAdmission(loginAdmission) + throw error + } + let mutationLease = authenticationMutation.lease + guard accountRuntimeTransitionCoordinator.canCommitLoginAdmission(loginAdmission), + loginSession == nil else { + await accountRegistry.finishMutation(mutationLease) + accountRuntimeTransitionCoordinator.finishLoginAdmission(loginAdmission) + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + let purpose = authenticationMutation.purpose + let generationID = UUID() + let runtimeProvider: @MainActor @Sendable (LoginPurpose) async throws -> LoginRuntime = { + [weak self] purpose in + guard let self else { + throw CodexReviewAuthenticationFailure.runtime( + message: "The review store was released while authentication was starting." + ) + } + return try await self.loginRuntime(for: purpose) + } + let urlOpener = externalURLOpener + let session = LoginSession( + generationID: generationID, + purpose: purpose, + previousActiveAccountKey: authenticationMutation.previousActiveAccountKey, + mutationLease: mutationLease, + cancellationTimeout: .seconds(5), + rootOperation: { @MainActor [weak self, weak auth] operationState, startCompletion in + let finish: @MainActor (LoginRootObservation) -> LoginRootObservation = { observation in + self?.publishLoginRootObservation( + observation, + generationID: generationID + ) + return observation + } + let runtime: LoginRuntime + do { + runtime = try await runtimeProvider(purpose) + } catch { + let failure = (error as? CodexReviewAuthenticationFailure) + ?? .runtime(message: error.localizedDescription) + let failureDisposition = await operationState.claimPreCommitFailure() + if case .cancel = failureDisposition { + await startCompletion.resolve(.success(())) + return finish(.outcome(.cancelled)) + } else { + await startCompletion.resolve(.failure(failure)) + return finish(.failure(failure)) + } + } + guard case .proceed = await operationState.bind(runtime: runtime) else { + await startCompletion.resolve(.success(())) + return finish(.outcome(.cancelled)) + } + + let handle: CodexLoginHandle + do { + handle = try await runtime.appServer.loginChatGPT( + accountReadinessTimeout: .seconds(5) + ) + } catch { + let failure = (error as? CodexReviewAuthenticationFailure) + ?? .runtime(message: error.localizedDescription) + let failureDisposition = await operationState.claimPreCommitFailure() + if case .cancel = failureDisposition { + await startCompletion.resolve(.success(())) + return finish(.outcome(.cancelled)) + } else { + await startCompletion.resolve(.failure(failure)) + return finish(.failure(failure)) + } + } + + let handleDisposition = await operationState.bind( + handle: handle, + runtime: runtime + ) + if case .cancel = handleDisposition { + await startCompletion.resolve(.success(())) + do { + return finish(.outcome(try await handle.cancel( + acknowledgementTimeout: .seconds(5) + ))) + } catch is CancellationError { + return finish(.waiterCancelled(message: nil)) + } catch { + return finish(.waiterCancelled(message: error.localizedDescription)) + } + } + + await self?.authenticationHandleDidBind?() + if case .cancel = await operationState.claimURLPresentation(handle: handle) { + await startCompletion.resolve(.success(())) + do { + return finish(.outcome(try await handle.result())) + } catch is CancellationError { + return finish(.waiterCancelled(message: nil)) + } catch { + return finish(.waiterCancelled(message: error.localizedDescription)) + } + } + + auth?.updatePhase(.signingIn(.init( + title: "Sign in to Codex", + detail: "Continue signing in with your browser.", + browserURL: handle.authenticationURL.absoluteString, + userCode: nil + ))) + + do { + try urlOpener(handle.authenticationURL) + await startCompletion.resolve(.success(())) + } catch { + let failure = CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) + do { + switch try await handle.cancel(acknowledgementTimeout: .seconds(5)) { + case .succeeded, + .authenticationCommittedNeedsConnectionReconciliation: + await startCompletion.resolve(.success(())) + return finish(.outcome(try await handle.result())) + case .failed(let message): + let loginFailure = CodexReviewAuthenticationFailure.login( + message: message ?? "Authentication failed." + ) + await startCompletion.resolve(.failure(loginFailure)) + return finish(.outcome(.failed(message: message))) + case .cancelled: + await startCompletion.resolve(.failure(failure)) + return finish(.failure(failure)) + } + } catch { + await startCompletion.resolve(.failure(failure)) + return finish(.failure(failure)) + } + } + + do { + return finish(.outcome(try await handle.result())) + } catch is CancellationError { + return finish(.waiterCancelled(message: nil)) + } catch { + return finish(.failure(.runtime(message: error.localizedDescription))) + } + }, + terminationHandler: { @MainActor [weak self, weak auth] session, reason, observation in guard let self, let auth else { - return + return .stopped } - await self.monitorAuthenticationSession( - challenge: challenge, + return await self.finishLoginSession( + session, + reason: reason, + observation: observation, + auth: auth + ) + } + ) + // The session owns the mutation lease and cancellation intent before either + // runtime acquisition or account/login/start can suspend. + loginSession = session + if case .signIn = purpose { + accountRuntimeTransitionCoordinator.retainPrimaryLoginAdmission(loginAdmission) + primaryLoginAdmission = (generationID, loginAdmission) + } else { + accountRuntimeTransitionCoordinator.finishLoginAdmission(loginAdmission) + } + let startResult = await session.activate() + if case .failure(let failure) = startResult { + let terminal = await session.terminate(reason: .rootOutcome) + if case .addAccountPreservingActive = purpose, + case .failed(let terminalFailure) = terminal { + throw terminalFailure + } + _ = failure + } + } + + private func publishLoginRootObservation( + _ observation: LoginRootObservation, + generationID: UUID + ) { + guard let session = loginSession, + session.generationID == generationID else { + return + } + session.publishRootObservation(observation) + } + + private func finishLoginSession( + _ session: LoginSession, + reason: LoginTerminationReason, + observation: LoginRootObservation, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { + let terminal: LoginSessionTerminal + switch observation { + case .outcome(let outcome): + terminal = await finishLoginOutcome( + outcome, + session: session, + reason: reason, + auth: auth + ) + case .failure(let failure): + auth.updatePhase(.failed(failure)) + terminal = .failed(failure) + case .waiterCancelled(let message): + terminal = finishCancelledLoginWaiter( + session: session, + reason: reason, + message: message, + auth: auth + ) + } + + await closeLoginRuntimeIfNeeded(session) + if case .primaryRuntimeReconciliation(let handoff) = terminal { + let requiresRuntimeStopHandoff: Bool = switch reason { + case .runtimeFailure, .storeStop: + true + case .rootOutcome, .explicitCancellation, .urlOpenFailure: + false + } + if requiresRuntimeStopHandoff { + finishPrimaryLoginAdmissionIfCurrent(session) + return terminal + } else { + guard let primaryAdmission = takePrimaryLoginAdmissionIfCurrent(session) else { + preconditionFailure("A direct primary reconciliation requires retained login ownership.") + } + let disposition = accountRuntimeTransitionCoordinator + .handoffPrimaryLoginToReconciliation( + primaryAdmission + ) { + [weak self, weak auth] reservation in + guard let self, let auth else { + return + } + await self.performPrimaryAuthenticationReconciliation( + handoff, + reservation: reservation, + auth: auth, + oldRuntimeAlreadyStopped: false + ) + } + switch disposition { + case .handedOff: + session.claimPrimaryAuthenticationHandoffForDirectReconciliation(handoff) + installActivePrimaryAuthenticationReconciliation(handoff) + clearLoginSessionIfCurrent(session) + case .deferUntilRuntimeStop: + accountRuntimeTransitionCoordinator.finishPrimaryLoginAdmission( + primaryAdmission + ) + return terminal + } + } + } else { + await releaseLoginMutationIfNeeded(session) + clearLoginSessionIfCurrent(session) + finishPrimaryLoginAdmissionIfCurrent(session) + await reconcilePendingRuntimeAuthInvalidation(auth: auth) + if case .succeeded = terminal, + case .signIn = session.purpose { + await refreshSelectedAccountRateLimits(auth: auth) + } + } + return terminal + } + + private func reconcilePendingRuntimeAuthInvalidation( + auth: CodexReviewAuthModel + ) async { + guard loginSession == nil, + let store = attachedStore, + let session = runtimeSession, + session.isActive, + session.hasCurrentAccountObservation == false, + let backend = session.activeRuntime?.backend else { + return + } + await refreshAuthAfterAccountNotification( + generation: session.generation, + backend: backend, + store: store + ) + } + + private func schedulePendingRuntimeAuthDrain() { + precondition( + pendingRuntimeAuthDrainRequestRevision < .max, + "A pending runtime-auth drain request revision must not wrap." + ) + pendingRuntimeAuthDrainRequestRevision += 1 + startPendingRuntimeAuthDrainIfNeeded() + } + + private func startPendingRuntimeAuthDrainIfNeeded() { + guard pendingRuntimeAuthDrainTask == nil else { return } + let requestRevision = pendingRuntimeAuthDrainRequestRevision + pendingRuntimeAuthDrainTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self else { return } + if let auth = self.attachedStore?.auth { + await self.reconcilePendingRuntimeAuthInvalidation(auth: auth) + } + self.pendingRuntimeAuthDrainTask = nil + if self.pendingRuntimeAuthDrainRequestRevision != requestRevision { + self.startPendingRuntimeAuthDrainIfNeeded() + } + } + } + + private func finishPrimaryLoginAdmissionIfCurrent(_ session: LoginSession) { + guard let admission = takePrimaryLoginAdmissionIfCurrent(session) else { + return + } + accountRuntimeTransitionCoordinator.finishPrimaryLoginAdmission( + admission + ) + } + + private func takePrimaryLoginAdmissionIfCurrent( + _ session: LoginSession + ) -> AccountRuntimeTransitionCoordinator.LoginAdmission? { + guard let primaryLoginAdmission, + primaryLoginAdmission.generationID == session.generationID else { + return nil + } + self.primaryLoginAdmission = nil + return primaryLoginAdmission.admission + } + + private func claimLoginResultPublication( + for session: LoginSession, + usesPrimaryRuntime: Bool + ) -> Bool { + guard usesPrimaryRuntime else { + return accountRuntimeTransitionCoordinator.commitLoginResultPublication() + } + guard let primaryLoginAdmission, + primaryLoginAdmission.generationID == session.generationID else { + return false + } + return accountRuntimeTransitionCoordinator.claimPrimaryLoginResultPublication( + primaryLoginAdmission.admission + ) + } + + private func finishLoginOutcome( + _ outcome: CodexLoginOutcome, + session: LoginSession, + reason terminationReason: LoginTerminationReason, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { + switch outcome { + case .succeeded: + return await finishSuccessfulLogin(session: session, auth: auth) + case .failed(let message): + let failure = CodexReviewAuthenticationFailure.login( + message: message ?? "Authentication failed." + ) + auth.updatePhase(.failed(failure)) + return .failed(failure) + case .cancelled: + return finishCancelledLoginOutcome( + reason: terminationReason, + auth: auth + ) + case .authenticationCommittedNeedsConnectionReconciliation(let reconciliationReason): + if case .cancelOutcomeUnknown = reconciliationReason { + guard case .signIn = session.purpose else { + return finishCancelledLoginOutcome( + reason: terminationReason, + auth: auth + ) + } + return .primaryRuntimeReconciliation( + session.takePrimaryAuthenticationReconciliationHandoff( + cause: .cancelOutcomeUnknown( + previousActiveAccountKey: session.previousActiveAccountKey + ) + ) + ) + } + 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: .committed(reconciliationReason) + ) + ) + } + } + + private func finishSuccessfulLogin( + session: LoginSession, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { + 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) + } + var stagingURLRequiringRemoval: URL? + var deferredPrimaryExpectation: ExpectedRuntimeAccount = .anyChatGPT + var primaryObservationPublication: ( + session: HostRuntimeSession, + observation: RuntimeAccountObservation, + revision: UInt64 + )? + do { + let mutationLease = session.mutationLeaseForOwnedOperation() + let reconciliation: ( + persisted: AccountRegistryStore.Snapshot, + account: CodexReviewAccount? + ) + if loginRuntime.usesPrimaryRuntime { + while true { + guard let primaryRuntimeSession = runtimeSession, + primaryRuntimeSession.isActive else { + throw CodexReviewAuthenticationFailure.runtime( + message: "The primary login runtime stopped before authentication reconciliation." + ) + } + let revision = primaryRuntimeSession.accountInvalidationRevision + let snapshot: CodexReviewBackendModel.Auth.Snapshot + do { + snapshot = try await loginRuntime.backend.readAuth() + } catch { + if runtimeSession !== primaryRuntimeSession + || primaryRuntimeSession.accountInvalidationRevision != revision { + deferredPrimaryExpectation = .reconcileCurrentRuntime + } + throw error + } + guard runtimeSession === primaryRuntimeSession, + primaryRuntimeSession.isActive else { + throw CodexReviewAuthenticationFailure.runtime( + message: "The primary login runtime stopped during authentication reconciliation." + ) + } + if primaryRuntimeSession.accountInvalidationRevision != revision { + deferredPrimaryExpectation = .reconcileCurrentRuntime + continue + } + let account = try successfulLoginAccount( + from: snapshot, + previousActiveAccountKey: session.previousActiveAccountKey + ) + deferredPrimaryExpectation = .observedAccount( + accountKey: account.accountKey, + provider: .chatGPT + ) + let candidate: ( + persisted: AccountRegistryStore.Snapshot, + account: CodexReviewAccount? + ) + do { + candidate = try await reconcileAuthSnapshotSerialized( + snapshot, + activation: session.purpose.activation, + authSourceCodexHomeURL: loginRuntime.codexHomeURL, + authenticatedRateLimits: nil, + authorization: mutationLease + ) + } catch { + if runtimeSession !== primaryRuntimeSession + || primaryRuntimeSession.accountInvalidationRevision != revision { + deferredPrimaryExpectation = .reconcileCurrentRuntime + } + throw error + } + guard runtimeSession === primaryRuntimeSession, + primaryRuntimeSession.isActive else { + throw CodexReviewAuthenticationFailure.runtime( + message: "The primary login runtime stopped after authentication reconciliation." + ) + } + guard primaryRuntimeSession.accountInvalidationRevision == revision else { + deferredPrimaryExpectation = .reconcileCurrentRuntime + continue + } + primaryObservationPublication = ( + primaryRuntimeSession, + .account(accountKey: account.accountKey, provider: .chatGPT), + revision + ) + reconciliation = candidate + break + } + } else { + let snapshot = try await loginRuntime.backend.readAuth() + _ = try successfulLoginAccount( + from: snapshot, + previousActiveAccountKey: session.previousActiveAccountKey + ) + let isolatedRateLimits = try? await loginRuntime.backend.readRateLimits() + guard let runtime = await session.takeOwnedRuntimeForClose() else { + preconditionFailure("An isolated login runtime can be closed only once.") + } + await appServerCloser(runtime.appServer) + stagingURLRequiringRemoval = runtime.codexHomeURL + reconciliation = try await reconcileAuthSnapshotSerialized( + snapshot, + activation: session.purpose.activation, + authSourceCodexHomeURL: loginRuntime.codexHomeURL, + authenticatedRateLimits: isolatedRateLimits, + authorization: mutationLease, + isolatedProductCommitAuthorization: mutationLease + ) + } + if claimLoginResultPublication( + for: session, + usesPrimaryRuntime: loginRuntime.usesPrimaryRuntime + ) { + if let primaryObservationPublication { + primaryObservationPublication.session.updateAccountObservation( + primaryObservationPublication.observation, + revision: primaryObservationPublication.revision + ) + } + applyAccountRegistrySnapshot(reconciliation.persisted, to: auth) + auth.updatePhase(.signedOut) + } + if let stagingURLRequiringRemoval { + await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval) + } + return .succeeded + } catch { + if let stagingURLRequiringRemoval { + await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval) + } + if error is IsolatedLoginProductCommitCancelled { + auth.updatePhase(.signedOut) + return .cancelled + } + if let productCommitFailure = error as? IsolatedLoginProductCommitFailure { + auth.updatePhase(.failed(productCommitFailure.failure)) + return .failed(productCommitFailure.failure) + } + if loginRuntime.usesPrimaryRuntime { + return await finishPrimaryLoginWithDeferredRegistryReconciliation( session: session, - completesLoginThroughCallback: nativeCallbackScheme != nil, + expectedAccount: deferredPrimaryExpectation, + underlyingError: error, auth: auth ) } + let failure = (error as? CodexReviewAuthenticationFailure) + ?? CodexReviewAuthenticationFailure.login(message: error.localizedDescription) + switch await session.claimPreCommitFailure() { + case .cancel: + auth.updatePhase(.signedOut) + return .cancelled + case .fail: + auth.updatePhase(.failed(failure)) + return .failed(failure) + } + } + } + + private func successfulLoginAccount( + from snapshot: CodexReviewBackendModel.Auth.Snapshot, + previousActiveAccountKey: String? + ) throws -> CodexReviewAccount { + guard let activeAccountID = snapshot.activeAccountID, + let backendAccount = snapshot.accounts.first(where: { $0.id == activeAccountID }), + let account = Self.monitorAccount(from: backendAccount), + account.kind == .chatGPT, + account.accountKey != previousActiveAccountKey else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "A successful login must expose a new active ChatGPT account." + ) + } + return account + } + + private func finishPrimaryLoginWithDeferredRegistryReconciliation( + session: LoginSession, + expectedAccount: ExpectedRuntimeAccount, + underlyingError: any Error, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { + let message = "Authentication succeeded, but account registry reconciliation remains pending: " + + underlyingError.localizedDescription + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message + ) } catch { - logger.error("ChatGPT login failed to start: \(error.localizedDescription, privacy: .public)") - let pendingLoginBackend = loginBackend - let pendingLoginChallenge = loginChallenge - loginChallenge = nil - loginBackend = nil - isWaitingForLoginAccountUpdate = false - let loginAppServer = loginAppServer ?? isolatedLoginAppServer - self.loginAppServer = nil - let loginCodexHomeURL = loginCodexHomeURL ?? isolatedLoginCodexHomeURL - self.loginCodexHomeURL = nil - activeAuthenticationSession = nil - authenticationTask?.cancel() - authenticationTask = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - if let pendingLoginBackend, let pendingLoginChallenge { - try? await pendingLoginBackend.cancelLogin(pendingLoginChallenge) - } - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - updateAuthenticationFailure( - error.localizedDescription, - auth: auth, - activation: activation + preconditionFailure( + "Committed primary authentication must durably record reconciliation debt: \(error.localizedDescription)" ) } + if commitPrimaryLoginReconciliationFailure(for: session) { + auth.updatePhase(.failed(.accountCommit(message: message))) + attachedStore?.transitionToFailed(message) + } + logger.error("\(message, privacy: .public)") + return .committedNeedsRuntimeReconciliation(message: message) + } + + private func commitPrimaryLoginReconciliationFailure( + for session: LoginSession + ) -> Bool { + guard let primaryLoginAdmission, + primaryLoginAdmission.generationID == session.generationID else { + return accountRuntimeTransitionCoordinator.commitUnownedReconciliationFailure() + } + return accountRuntimeTransitionCoordinator.commitPrimaryLoginReconciliationFailure( + primaryLoginAdmission.admission + ) + } + + private func finishCancelledLoginOutcome( + reason: LoginTerminationReason, + auth: CodexReviewAuthModel + ) -> LoginSessionTerminal { + switch reason { + case .urlOpenFailure(let failure), .runtimeFailure(let failure): + auth.updatePhase(.failed(failure)) + return .failed(failure) + case .storeStop: + auth.updatePhase(.signedOut) + return .stopped + case .rootOutcome, .explicitCancellation: + auth.updatePhase(.signedOut) + return .cancelled + } + } + + private func finishCancelledLoginWaiter( + session _: LoginSession, + reason: LoginTerminationReason, + message: String?, + auth: CodexReviewAuthModel + ) -> LoginSessionTerminal { + finishLoginWaiterFailure( + reason: reason, + message: message ?? "Authentication cancellation failed.", + auth: auth + ) } - private func monitorAuthenticationSession( - challenge: CodexReviewBackendModel.Login.Challenge, - session: any CodexReviewNativeAuthentication.WebSession, - completesLoginThroughCallback: Bool, + private func finishLoginWaiterFailure( + reason: LoginTerminationReason, + message: String, auth: CodexReviewAuthModel + ) -> LoginSessionTerminal { + switch reason { + case .rootOutcome: + let failure = CodexReviewAuthenticationFailure.login(message: message) + auth.updatePhase(.failed(failure)) + return .failed(failure) + case .explicitCancellation: + let failure = CodexReviewAuthenticationFailure.runtime(message: message) + auth.updatePhase(.failed(failure)) + return .failed(failure) + case .urlOpenFailure(let failure), .runtimeFailure(let failure): + auth.updatePhase(.failed(failure)) + return .failed(failure) + case .storeStop: + auth.updatePhase(.signedOut) + return .stopped + } + } + + private func performPrimaryAuthenticationReconciliation( + _ handoff: PrimaryAuthenticationReconciliationHandoff, + reservation: AccountRuntimeTransitionCoordinator.PrimaryReconciliationReservation, + auth: CodexReviewAuthModel, + oldRuntimeAlreadyStopped: Bool ) async { + let finalResult: PrimaryAuthenticationReconciliationResult + let expectedAccount: ExpectedRuntimeAccount = switch handoff.cause { + case .committed: + .anyChatGPT + case .cancelOutcomeUnknown(let previousActiveAccountKey): + .cancelOutcomeUnknown(previousActiveAccountKey: previousActiveAccountKey) + } do { - let callbackURL = try await session.waitForCallbackURL() - guard loginChallenge?.id == challenge.id else { - return + guard let store = attachedStore else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication committed, but the review store is unavailable for reconciliation." + ) } - guard completesLoginThroughCallback else { - logger.info("Authentication session completed; waiting for app-server login completion notification") - return + if oldRuntimeAlreadyStopped == false { + await stop(store: store, purpose: .loginReconciliationPreservingRuns) + } + guard await startRuntime( + store: store, + forceRestartIfNeeded: true, + expectedAccount: expectedAccount, + mode: accountRuntimeTransitionCoordinator.primaryPublicationClaim(reservation) == .published + ? .published(owner: .primary(reservation)) + : .quiescentReconciliation, + registryAuthorization: handoff.mutationLease, + accountSnapshotForPublication: nil + ) else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication committed, but the replacement runtime failed validation." + ) } - guard let loginBackend else { - throw CodexReviewAPI.Error.io("Authentication runtime is not available.") + guard let accountObservation = runtimeSession?.accountObservation else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication reconciliation completed without a validated account observation." + ) } - try await loginBackend.completeLogin(challenge, callbackURL: callbackURL) - guard loginChallenge?.id == challenge.id else { - return + switch (handoff.cause, accountObservation) { + case (.committed, .account(let accountKey, .chatGPT)): + finalResult = .authenticated(accountKey: accountKey) + case (.cancelOutcomeUnknown(let previousActiveAccountKey), .account(let accountKey, .chatGPT)) + where accountKey != previousActiveAccountKey: + finalResult = .authenticated(accountKey: accountKey) + case (.cancelOutcomeUnknown, .signedOut): + finalResult = .cancelled + case (.cancelOutcomeUnknown(let previousActiveAccountKey), .account(let accountKey, .chatGPT)) + where accountKey == previousActiveAccountKey: + finalResult = .cancelled + case (.committed, .signedOut), + (_, .invalid), + (_, .account(_, .apiKey)), + (_, .account(_, .amazonBedrock)), + (.cancelOutcomeUnknown, .account(_, .chatGPT)): + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "Authentication reconciliation produced an invalid account observation." + ) } - logger.info("Authentication session completed; waiting for app-server login completion notification") - } catch is CancellationError { - await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth) - } catch CodexReviewNativeAuthenticationError.cancelled { - await handleAuthenticationSessionCancelled(challenge: challenge, auth: auth) } catch { - guard loginChallenge?.id == challenge.id else { - return + let message = "Authentication was committed, but runtime reconciliation remains pending: \(error.localizedDescription)" + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: expectedAccount, + message: message + ) + } catch { + preconditionFailure( + "Committed primary authentication must durably record reconciliation debt: \(error.localizedDescription)" + ) } - logger.error("ChatGPT login failed to complete: \(error.localizedDescription, privacy: .public)") - let loginAppServer = loginAppServer - let loginCodexHomeURL = loginCodexHomeURL - loginChallenge = nil - self.loginBackend = nil - isWaitingForLoginAccountUpdate = false - self.loginAppServer = nil - self.loginCodexHomeURL = nil - activeAuthenticationSession = nil - authenticationTask = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - updateAuthenticationFailure( - error.localizedDescription, - auth: auth, - activation: loginActivation + if accountRuntimeTransitionCoordinator.commitPrimaryReconciliationFailure(reservation) { + attachedStore?.transitionToFailed(message) + auth.updatePhase(.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 accountRegistry.finishMutation(handoff.mutationLease) + let didResolve = handoff.finalResult.resolve(finalResult) + precondition( + didResolve, + "A primary authentication reconciliation resolver can complete only once." + ) + clearActivePrimaryAuthenticationReconciliation(handoff) } - private func updateAuthenticationFailure( - _ message: String, - auth: CodexReviewAuthModel, - activation: LoginActivation + private func installActivePrimaryAuthenticationReconciliation( + _ handoff: PrimaryAuthenticationReconciliationHandoff + ) { + precondition( + activePrimaryAuthenticationReconciliation == nil, + "Only one primary authentication reconciliation can own the active final-result slot." + ) + activePrimaryAuthenticationReconciliation = ( + loginGenerationID: handoff.loginGenerationID, + finalResult: handoff.finalResult + ) + } + + private func clearActivePrimaryAuthenticationReconciliation( + _ handoff: PrimaryAuthenticationReconciliationHandoff ) { - switch activation { - case .activateAuthenticatedAccount: - auth.updatePhase(.failed(message: message)) - case .preserveActiveAccount: - auth.recordAuthenticationFailure(message: message) + guard let activePrimaryAuthenticationReconciliation else { + preconditionFailure("A primary authentication reconciliation must resolve its active final-result slot.") + } + precondition( + activePrimaryAuthenticationReconciliation.loginGenerationID == handoff.loginGenerationID + && activePrimaryAuthenticationReconciliation.finalResult === handoff.finalResult, + "Only the active primary authentication reconciliation can clear its final-result slot." + ) + self.activePrimaryAuthenticationReconciliation = nil + } + + private func clearLoginSessionIfCurrent(_ session: LoginSession) { + guard loginSession === session, + loginSession?.generationID == session.generationID else { + return + } + loginSession = nil + } + + private func closeLoginRuntime(_ runtime: LoginRuntime?) async { + guard let runtime else { + return + } + guard runtime.usesPrimaryRuntime == false else { + return + } + await closeIsolatedLoginRuntime( + appServer: runtime.appServer, + codexHomeURL: runtime.codexHomeURL + ) + } + + private func closeLoginRuntimeIfNeeded(_ session: LoginSession) async { + guard let runtime = await session.takeOwnedRuntimeForClose() else { + return + } + await closeLoginRuntime(runtime) + } + + private func releaseLoginMutationIfNeeded(_ session: LoginSession) async { + guard let lease = session.takeMutationLeaseForRelease() else { + return } + await accountRegistry.finishMutation(lease) } - private func loginRuntime(for activation: LoginActivation) async throws -> LoginRuntime { - switch activation { - case .activateAuthenticatedAccount: + private func loginRuntime(for purpose: LoginPurpose) async throws -> LoginRuntime { + switch purpose { + case .signIn: guard let appServer, let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } @@ -1017,10 +2858,24 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { codexHomeURL: codexHomeURL, usesPrimaryRuntime: true ) - case .preserveActiveAccount: - let temporaryCodexHomeURL = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true) - let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) + case .addAccountPreservingActive: + let temporaryCodexHomeURL = try await accountRegistry.reserveTemporaryCodexHome( + kind: .authentication + ) + let runtime: AppServerRuntime + do { + runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) + } catch { + await accountRegistry.finishTemporaryCodexHome(temporaryCodexHomeURL) + throw error + } + guard appServerBackend != nil else { + await closeIsolatedLoginRuntime( + appServer: runtime.appServer, + codexHomeURL: temporaryCodexHomeURL + ) + throw CodexReviewAPI.Error.io("Review runtime is not running.") + } return .init( appServer: runtime.appServer, backend: runtime.backend, @@ -1030,216 +2885,297 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func handleAuthenticationSessionCancelled( - challenge: CodexReviewBackendModel.Login.Challenge, - auth: CodexReviewAuthModel - ) async { - guard loginChallenge?.id == challenge.id else { - return - } - logger.info("ChatGPT login session was cancelled") - let loginBackend = loginBackend - let loginAppServer = loginAppServer - let loginCodexHomeURL = loginCodexHomeURL - if let loginBackend { - do { - try await loginBackend.cancelLogin(challenge) - } catch { - logger.error("Failed to cancel ChatGPT login after session close: \(error.localizedDescription, privacy: .public)") - } - } - loginChallenge = nil - self.loginBackend = nil - isWaitingForLoginAccountUpdate = false - self.loginAppServer = nil - self.loginCodexHomeURL = nil - activeAuthenticationSession = nil - authenticationTask = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - auth.updatePhase(.signedOut) - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - } - func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { + try requireOperationAdmission() guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } return try await appServerBackend.startReview(request) } - func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws { - guard let appServerBackend else { + func interruptReview(_ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws { + guard let appServerBackend = teardownAppServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } - try await appServerBackend.interruptReview(run, reason: reason) + try await appServerBackend.interruptReview(attempt, reason: reason) } - func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken { + func prepareReviewRestart(_ attempt: ReviewAttempt) async throws -> CodexReviewBackendModel.Review.RestartToken { + try requireOperationAdmission() guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } - return try await appServerBackend.prepareReviewRestart(run) + return try await appServerBackend.prepareReviewRestart(attempt) } func restartPreparedReview( _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { + try requireOperationAdmission() guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } return try await appServerBackend.restartPreparedReview(token, request: request) } - func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - guard let appServerBackend else { + func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + guard let appServerBackend = teardownAppServerBackend else { + preconditionFailure( + "A prepared review restart must retain its matching app-server runtime until discard completes." + ) + } + return await appServerBackend.discardPreparedReviewRestart(token) + } + + func cleanupReview(_ attempt: ReviewAttempt) async { + guard let appServerBackend = teardownAppServerBackend else { return } - await appServerBackend.cleanupReview(run) + await appServerBackend.cleanupReview(attempt) } - @discardableResult - private func applyAuthSnapshot( - _ snapshot: CodexReviewBackendModel.Auth.Snapshot, - to auth: CodexReviewAuthModel, - activation: LoginActivation = .activateAuthenticatedAccount, - authSourceCodexHomeURL: URL? = nil - ) -> CodexReviewAccount? { - guard let activeAccountID = snapshot.activeAccountID?.rawValue, - let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), - let account = Self.monitorAccount(from: backendAccount) - else { - if case .activateAuthenticatedAccount = activation { - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - } else { - auth.updatePhase(.signedOut) + func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + guard let appServerBackend = teardownAppServerBackend else { + let attemptFailures = attempts.flatMap { attempt -> [ReviewRetainedThreadCleanupFailure] in + if attempt.threadIdentity.activeTurnThreadID == attempt.threadIdentity.sourceThreadID { + return [.init( + threadID: attempt.threadIdentity.sourceThreadID, + message: "The matching review runtime is not running." + )] + } + return [ + .init( + threadID: attempt.threadIdentity.activeTurnThreadID, + message: "The matching review runtime is not running." + ), + .init( + threadID: attempt.threadIdentity.sourceThreadID, + message: "The matching review runtime is not running." + ), + ] } - return nil - } - var persistedAccounts = auth.persistedAccounts - let persistedAccount: CodexReviewAccount - if let index = persistedAccounts.firstIndex(where: { $0.accountKey == account.accountKey }) { - persistedAccounts[index].updateEmail(account.email) - persistedAccounts[index].updateKind(account.kind, capabilities: account.capabilities) - persistedAccounts[index].updatePlanType(account.planType) - persistedAccount = persistedAccounts[index] - } else { - persistedAccounts.insert(account, at: 0) - persistedAccount = account + return .init(failures: attemptFailures + additionalThreadIDs.map { threadID in + .init( + threadID: threadID, + message: "The matching review runtime is not running." + ) + }) } - let activeAccountKey = activation.resolvedActiveAccountKey( - authenticatedAccountKey: account.accountKey, - persistedAccounts: persistedAccounts + return await appServerBackend.cleanupRetainedReviews( + attempts, + additionalThreadIDs: additionalThreadIDs ) - try? CodexReviewAccountRegistry.saveAccounts( - persistedAccounts, - activeAccountKey: activeAccountKey, - codexHomeURL: codexHomeURL + } + + private func applyAccountRegistrySnapshot( + _ snapshot: AccountRegistryStore.Snapshot, + to auth: CodexReviewAuthModel + ) { + auth.applyPersistedAccountStates( + snapshot.accounts, + activeAccountKey: snapshot.activeAccountKey ) - switch activation { - case .activateAuthenticatedAccount: - try? CodexReviewAccountRegistry.saveSharedAuth( - for: account, - codexHomeURL: codexHomeURL - ) - case .preserveActiveAccount: - if let authSourceCodexHomeURL { - try? CodexReviewAccountRegistry.saveSharedAuth( - from: authSourceCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL + auth.selectPersistedAccount(snapshot.activeAccountKey) + } + + private func reconcileAuthSnapshotSerialized( + _ snapshot: CodexReviewBackendModel.Auth.Snapshot, + activation: LoginActivation = .activateAuthenticatedAccount, + authSourceCodexHomeURL: URL? = nil, + authenticatedRateLimits: CodexRateLimits? = nil, + authorization: AccountRegistryStore.MutationLease? = nil, + runtimeAuthorization: AccountRegistryStore.RuntimeCommitAuthorization? = nil, + isolatedProductCommitAuthorization: AccountRegistryStore.MutationLease? = nil + ) async throws -> ( + persisted: AccountRegistryStore.Snapshot, + account: CodexReviewAccount? + ) { + guard let activeAccountID = snapshot.activeAccountID?.rawValue.nilIfEmpty else { + guard case .activateAuthenticatedAccount = activation else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "An isolated successful login did not expose its authenticated account." ) } + let persisted = try await accountRegistry.deactivateAccount( + authorization: authorization, + runtimeAuthorization: runtimeAuthorization + ) + return (persisted, nil) } - auth.applyPersistedAccountStates( - persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) - auth.selectPersistedAccount(activeAccountKey) - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) - return auth.persistedAccounts.first(where: { $0.accountKey == persistedAccount.accountKey }) + guard let backendAccount = snapshot.accounts.first(where: { + $0.id.rawValue == activeAccountID + }) else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The active runtime account identifier is missing from the account snapshot." + ) + } + guard let account = Self.monitorAccount(from: backendAccount) else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The active runtime account snapshot is invalid." + ) + } + if let authenticatedRateLimits { + applyRateLimits(authenticatedRateLimits, to: account) + } + let accountPayload = savedAccountPayload(from: account) + let persisted: AccountRegistryStore.Snapshot + if accountPayload.kind == .chatGPT { + persisted = try await accountRegistry.commitAuthenticatedAccount( + accountPayload, + activation: activation, + authSourceCodexHomeURL: authSourceCodexHomeURL, + authorization: authorization, + runtimeAuthorization: runtimeAuthorization, + isolatedProductCommitAuthorization: isolatedProductCommitAuthorization + ) + } else { + persisted = try await accountRegistry.upsertAccount( + accountPayload, + activation: activation, + authorization: authorization, + runtimeAuthorization: runtimeAuthorization + ) + } + return (persisted, account) } - private func observeAuthNotifications( + private func installRuntimeConsumers( + session: HostRuntimeSession, appServer: CodexAppServer, - backend: AppServerCodexReviewBackend, store: CodexReviewStore - ) { - authNotificationTask?.cancel() - authNotificationTask = Task { @MainActor [weak self, weak store] in - guard let self, let store else { - return - } - let stream = await appServer.accountEvents() - do { - for try await event in stream { - await self.handleAuthNotification( - event, - backend: backend, - auth: store.auth - ) + ) async throws { + let generation = session.generation + let connectionEvents = await appServer.connectionEvents() + do { + try requireCurrentStagingSession(session) + } catch { + await connectionEvents.cancel() + throw error + } + let accountEvents = await appServer.accountEvents() + do { + try requireCurrentStagingSession(session) + } catch { + await accountEvents.cancel() + await connectionEvents.cancel() + throw error + } + session.installConsumers( + accountEvents: accountEvents, + connectionEvents: connectionEvents, + accountEventSink: { @MainActor [weak self, weak store] event in + guard let self, let store else { + return } - } catch is CancellationError { - } catch { - logger.error("Auth notification stream ended: \(error.localizedDescription, privacy: .public)") - await markRuntimeFailedAfterNotificationStreamError(error, store: store) + await self.handleRuntimeAccountEvent( + event, + generation: generation, + store: store + ) + }, + exitSink: { @MainActor [weak self, weak store] failure in + guard let self, let store else { + return + } + await self.runtimeConsumerDidExit( + generation: generation, + failure: failure, + store: store + ) } - } + ) } - // Dropping the container must always reach the lifecycle handler, or the - // ReviewMonitor window keeps its model source pointed at a closed - // app-server container. - private func clearAppServerModelContainer() { - appServerModelContainer = nil - appServerLifecycleHandler?(nil) + private func handleRuntimeAccountEvent( + _ event: CodexAccountEvent, + generation: UInt64, + store: CodexReviewStore + ) async { + guard let session = runtimeSession, + session.generation == generation else { + return + } + if case .accountUpdated = event { + session.recordAccountInvalidation() + } + guard let backend = session.activeRuntime?.backend else { + return + } + await handleAuthNotification( + event, + generation: generation, + backend: backend, + store: store + ) } - private func markRuntimeFailedAfterNotificationStreamError( - _ error: any Error, + private func runtimeConsumerDidExit( + generation: UInt64, + failure: HostRuntimeConsumerFailure, store: CodexReviewStore ) async { - let loginCleanup = takeLoginRuntimeForCleanup() - guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginCleanup.isEmpty == false else { + guard let session = runtimeSession, + session.generation == generation else { return } - let message = "Review runtime stopped unexpectedly: \(error.localizedDescription)" - if let appServerBackend { - let reason = ReviewCancellation.system(message: message) - await cleanupActiveReviewsForRuntimeTeardown( - store: store, - appServerBackend: appServerBackend, - reason: reason, - timeoutWarning: "Timed out cleaning active reviews after runtime failure" + switch session.phase { + case .staging: + session.recordStagingFailure(failure) + case .active: + guard session.isActive else { + logger.debug("Ignoring a runtime consumer exit after its generation admission closed") + return + } + let message = "Review runtime stopped unexpectedly: \(failure.message)" + store.transitionToFailed(message) + session.closeAdmission() + let requestedStopTask = session.requestStop(purpose: .runtimeRestartPreservingRuns) { session in + await self.accountRegistry.closeRuntimeAdmission(generation: generation) + return await self.performRuntimeStop( + session: session, + store: store, + reviewCleanupMode: .connectionTerminated, + reviewCancellation: .system(message: message), + loginTerminationReason: .runtimeFailure(.runtime(message: message)) + ) + } + guard let stopTask = requestedStopTask else { + return + } + _ = installRuntimeStopFollowup( + stopTask: stopTask, + session: session, + store: store ) + case .stopping, .stopIncomplete, .stopped: + return } - let failedAppServer = appServer - let failedMCPHTTPServer = mcpHTTPServer - appServer = nil - clearAppServerModelContainer() - appServerBackend = nil - mcpHTTPServer = nil - authNotificationTask = nil - store.transitionToFailed(message) - await failedMCPHTTPServer?.stop() - await cleanupLoginRuntime(loginCleanup) - await failedAppServer?.close() } private func handleAuthNotification( _ event: CodexAccountEvent, + generation: UInt64, backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel + store: CodexReviewStore ) async { switch event { - case .loginCompleted(let completion): - await handleLoginCompletedNotification(completion, backend: backend, auth: auth) case .accountUpdated: - await handleAccountUpdatedNotification(backend: backend, auth: auth) + schedulePendingRuntimeAuthDrain() case .rateLimitsUpdated(let rateLimits): - await applyRateLimitsUpdatedNotification(rateLimits, auth: auth) + guard acceptsRuntimeEvent(generation: generation) else { + return + } + await applyRateLimitsUpdatedNotification( + rateLimits, + generation: generation, + auth: store.auth + ) case .malformed(let method, let message): logger.error("Malformed account notification \(method, privacy: .public): \(message, privacy: .public)") case .unknown: @@ -1247,156 +3183,215 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func observeLoginNotifications( - appServer: CodexAppServer, + private func refreshAuthAfterAccountNotification( + generation: UInt64, backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) { - loginNotificationTask?.cancel() - loginNotificationTask = Task { @MainActor [weak self, weak auth] in - guard let self, let auth else { - return + store: CodexReviewStore + ) async { + guard let reservation = accountRuntimeTransitionCoordinator.reserveRuntimeAuthReconciliation( + generation: generation + ) else { + return + } + let didReconcile = await performRuntimeAuthReconciliation( + reservation: reservation, + generation: generation, + backend: backend, + auth: store.auth, + cause: .accountUpdated + ) + if didReconcile { + await refreshSelectedAccountRateLimits(auth: store.auth) + } + } + + private func performRuntimeAuthReconciliation( + reservation: AccountRuntimeTransitionCoordinator.RuntimeAuthReconciliation, + generation: UInt64, + backend: AppServerCodexReviewBackend, + auth: CodexReviewAuthModel, + cause: RuntimeAuthReconciliationCause + ) async -> Bool { + defer { + accountRuntimeTransitionCoordinator.finishRuntimeAuthReconciliation(reservation) + } + var requiresAuthoritativeRefresh = cause == .accountUpdated + while true { + guard let session = currentActiveRuntimeSession(generation: generation) else { + return false } - let stream = await appServer.accountEvents() + let readRevision = session.accountInvalidationRevision + let snapshot: CodexReviewBackendModel.Auth.Snapshot do { - for try await event in stream { - await self.handleLoginRuntimeNotification(event, backend: backend, auth: auth) + snapshot = try await backend.readAuth() + } catch { + guard let currentSession = currentActiveRuntimeSession(generation: generation) else { + return false + } + if currentSession.accountInvalidationRevision != readRevision { + requiresAuthoritativeRefresh = true + continue + } + let failure = (error as? CodexReviewAuthenticationFailure) + ?? CodexReviewAuthenticationFailure.runtime(message: error.localizedDescription) + if requiresAuthoritativeRefresh == false { + if accountRuntimeTransitionCoordinator.canPublishRuntimeAuthReadResult(reservation) { + auth.updatePhase(.failed(failure)) + } + } else { + if accountRuntimeTransitionCoordinator.commitRuntimeAuthReconciliationFailure(reservation) { + attachedStore?.transitionToFailed(failure.localizedDescription) + auth.updatePhase(.failed(failure)) + } + } + return false + } + + guard let currentSession = currentActiveRuntimeSession(generation: generation) else { + return false + } + if currentSession.accountInvalidationRevision != readRevision { + requiresAuthoritativeRefresh = true + continue + } + let observation = runtimeAccountObservation(from: snapshot) + guard let expectedAccount = observation.exactExpectation else { + let failure = CodexReviewAuthenticationFailure.protocolViolation( + message: "The active runtime authentication snapshot has an invalid account reference." + ) + if accountRuntimeTransitionCoordinator.commitRuntimeAuthReconciliationFailure(reservation) { + attachedStore?.transitionToFailed(failure.localizedDescription) + auth.updatePhase(.failed(failure)) } - } catch is CancellationError { + return false + } + guard accountRuntimeTransitionCoordinator.claimRuntimeAuthRegistryEffect(reservation) else { + return false + } + + let reconciliation: ( + persisted: AccountRegistryStore.Snapshot, + account: CodexReviewAccount? + ) + do { + reconciliation = try await reconcileAuthSnapshotSerialized( + snapshot, + runtimeAuthorization: runtimeCommitAuthorization(generation: generation) + ) } catch { - logger.error("Login notification stream ended: \(error.localizedDescription, privacy: .public)") + let message = "Runtime authentication changed, but its account registry reconciliation remains pending: " + + error.localizedDescription + let recoveryExpectation: ExpectedRuntimeAccount + if let currentSession = currentActiveRuntimeSession(generation: generation), + currentSession.accountInvalidationRevision == readRevision { + recoveryExpectation = expectedAccount + } else { + recoveryExpectation = .reconcileCurrentRuntime + } + do { + try await accountRegistry.recordReconciliationDebt( + expectedAccount: recoveryExpectation, + message: message + ) + } catch { + preconditionFailure( + "An unresolved runtime authentication change must durably record reconciliation debt: \(error.localizedDescription)" + ) + } + if accountRuntimeTransitionCoordinator.commitRuntimeAuthReconciliationFailure(reservation) { + attachedStore?.transitionToFailed(message) + auth.updatePhase(.failed(.accountCommit(message: message))) + } + return false + } + + guard let committedSession = currentActiveRuntimeSession(generation: generation) else { + return false + } + guard committedSession.accountInvalidationRevision == readRevision else { + requiresAuthoritativeRefresh = true + guard accountRuntimeTransitionCoordinator + .continueRuntimeAuthReadingAfterRegistryCommit(reservation) else { + return false + } + continue + } + guard case .published = accountRuntimeTransitionCoordinator + .claimRuntimeAuthReconciliationPublication(reservation) else { + return false } + committedSession.updateAccountObservation( + observation, + revision: readRevision + ) + applyAccountRegistrySnapshot(reconciliation.persisted, to: auth) + auth.updatePhase(.signedOut) + return true } } - private func handleLoginRuntimeNotification( - _ event: CodexAccountEvent, - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - switch event { - case .loginCompleted(let completion): - await handleLoginCompletedNotification(completion, backend: backend, auth: auth) - case .accountUpdated: - guard loginBackend != nil, isWaitingForLoginAccountUpdate else { - return - } - await finishCompletedLoginAfterAccountUpdate(backend: backend, auth: auth) - case .rateLimitsUpdated, - .malformed, - .unknown: - return + private func currentActiveRuntimeSession( + generation: UInt64 + ) -> HostRuntimeSession? { + guard let session = runtimeSession, + session.generation == generation, + session.isActive else { + return nil } + return session } - private func handleLoginCompletedNotification( - _ completion: CodexLoginCompletion, - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - guard completion.loginID?.rawValue == nil || completion.loginID?.rawValue == loginChallenge?.id else { - return + private func isCurrentActiveRuntimeGeneration(_ generation: UInt64) -> Bool { + guard let session = runtimeSession, + session.generation == generation else { + return false } - loginChallenge = nil - let loginAppServer = loginAppServer - let loginCodexHomeURL = loginCodexHomeURL - let activeAuthenticationSession = activeAuthenticationSession - self.activeAuthenticationSession = nil - authenticationTask?.cancel() - authenticationTask = nil - await activeAuthenticationSession?.cancel() - guard completion.success else { - updateAuthenticationFailure( - completion.error ?? "Authentication failed.", - auth: auth, - activation: loginActivation - ) - self.loginBackend = nil - isWaitingForLoginAccountUpdate = false - self.loginAppServer = nil - self.loginCodexHomeURL = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - return + return session.isActive + } + + private func acceptsRuntimeEvent(generation: UInt64) -> Bool { + guard let session = runtimeSession, + session.generation == generation else { + return false } - isWaitingForLoginAccountUpdate = true - logger.info("ChatGPT login completed; waiting for account update notification") + return session.isActive && accountRuntimeTransitionCoordinator.acceptsNewOperations } - private func handleAccountUpdatedNotification( - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - guard isWaitingForLoginAccountUpdate else { - await refreshAuthAfterAccountNotification(backend: backend, auth: auth) - return + private func requireOperationAdmission() throws { + guard acceptsNewReviewOperations else { + throw CodexReviewAPI.Error.io("The review runtime is changing accounts or stopping.") } - await finishCompletedLoginAfterAccountUpdate(backend: backend, auth: auth) } - private func finishCompletedLoginAfterAccountUpdate( - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - let activation = loginActivation - let loginBackend = loginBackend - let loginAppServer = loginAppServer - let loginCodexHomeURL = loginCodexHomeURL - let activeAuthenticationSession = activeAuthenticationSession - do { - loginChallenge = nil - self.activeAuthenticationSession = nil - authenticationTask?.cancel() - authenticationTask = nil - await activeAuthenticationSession?.cancel() - let account = applyAuthSnapshot( - try await backend.readAuth(), - to: auth, - activation: activation, - authSourceCodexHomeURL: loginCodexHomeURL - ) - if case .preserveActiveAccount = activation, let account, let loginBackend { - let didRefresh = await refreshRateLimits(for: account, using: loginBackend, source: "login-runtime") - if didRefresh { - persistRefreshedSharedAuth( - from: loginCodexHomeURL, - for: account - ) - } - } else { - await refreshSelectedAccountRateLimits(auth: auth) - } - } catch { - updateAuthenticationFailure( - error.localizedDescription, - auth: auth, - activation: activation - ) + private func requireAdmittedRuntimeBackend() throws -> ( + generation: UInt64, + backend: AppServerCodexReviewBackend + ) { + try requireOperationAdmission() + guard let session = runtimeSession, + let backend = session.activeRuntime?.backend else { + throw CodexReviewAPI.Error.io("Review runtime is not running.") } - self.loginBackend = nil - self.loginAppServer = nil - self.loginCodexHomeURL = nil - isWaitingForLoginAccountUpdate = false - loginNotificationTask?.cancel() - loginNotificationTask = nil - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) + return (session.generation, backend) } - private func refreshAuthAfterAccountNotification( - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - do { - applyAuthSnapshot(try await backend.readAuth(), to: auth) - await refreshSelectedAccountRateLimits(auth: auth) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + private func requireRuntimeCommitAdmission(generation: UInt64) throws { + guard acceptsRuntimeEvent(generation: generation) else { + throw CodexReviewAPI.Error.io( + "The runtime generation that accepted this operation is no longer active." + ) } } + private func runtimeCommitAuthorization( + generation: UInt64 + ) -> AccountRegistryStore.RuntimeCommitAuthorization { + .init(generation: generation) + } + private func applyRateLimitsUpdatedNotification( _ rateLimits: CodexRateLimits, + generation: UInt64, auth: CodexReviewAuthModel ) async { guard let selectedAccount = auth.selectedAccount else { @@ -1405,10 +3400,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard selectedAccount.capabilities.supportsRateLimitRefresh else { return } + guard let session = runtimeSession, + session.generation == generation, + session.authorizeRateLimitObservation(for: selectedAccount) != nil else { + logger.debug("Dropping a sparse rate-limit notification without a matching runtime account observation") + return + } applyRateLimits(rateLimits, to: selectedAccount) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: selectedAccount, - codexHomeURL: codexHomeURL + _ = await persistRateLimitMetadata( + for: selectedAccount, + runtimeAuthorization: runtimeCommitAuthorization(generation: generation) ) } @@ -1427,69 +3428,162 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await refreshSavedAccountRateLimits(for: account) return } - let didRefresh = await refreshRateLimits(for: account, using: appServerBackend, source: "active-runtime") - if didRefresh { - persistRefreshedSharedAuth( - from: codexHomeURL, - for: account + await refreshActiveAccountRateLimits(for: account, auth: auth) + } + + private func refreshActiveAccountRateLimits( + for account: CodexReviewAccount, + auth: CodexReviewAuthModel + ) async { + guard let admitted = try? requireAdmittedRuntimeBackend() else { + return + } + guard let session = runtimeSession, + session.generation == admitted.generation, + let observationAuthorization = session.authorizeRateLimitObservation( + for: account + ) else { + logger.debug("Dropping a rate-limit refresh without an exact runtime account observation") + return + } + let authorization = runtimeCommitAuthorization(generation: admitted.generation) + do { + let beforeAuth = try await admitted.backend.readAuth() + try requireActiveRateLimitCommitAdmission( + authorization: observationAuthorization, + account: account, + auth: auth + ) + try requireRateLimitRuntimeIdentity( + beforeAuth, + authorization: observationAuthorization + ) + let response = try await admitted.backend.readRateLimits() + let afterAuth = try await admitted.backend.readAuth() + try requireActiveRateLimitCommitAdmission( + authorization: observationAuthorization, + account: account, + auth: auth + ) + try requireRateLimitRuntimeIdentity( + afterAuth, + authorization: observationAuthorization + ) + applyRateLimits(response, to: account) + guard await persistRateLimitMetadata( + for: account, + runtimeAuthorization: authorization + ) else { + return + } + try requireActiveRateLimitCommitAdmission( + authorization: observationAuthorization, + account: account, + auth: auth + ) + } catch ActiveRateLimitRefreshFailure.staleAccountIdentity { + logger.debug("Dropping a rate-limit refresh whose runtime account identity changed") + } catch { + guard (try? requireActiveRateLimitCommitAdmission( + authorization: observationAuthorization, + account: account, + auth: auth + )) != nil else { + logger.debug("Dropping a completed rate-limit refresh from a retired runtime generation") + return + } + recordRateLimitRefreshFailure(error, account: account) + _ = await persistRateLimitMetadata( + for: account, + runtimeAuthorization: authorization ) } } + private func requireActiveRateLimitCommitAdmission( + authorization: RuntimeAccountObservationAuthorization, + account: CodexReviewAccount, + auth: CodexReviewAuthModel + ) throws { + try requireRuntimeCommitAdmission(generation: authorization.generation) + guard let session = runtimeSession, + session.validatesRateLimitObservation(authorization) else { + throw ActiveRateLimitRefreshFailure.staleAccountIdentity + } + guard auth.persistedActiveAccountKey == account.accountKey, + auth.selectedAccount === account else { + throw ActiveRateLimitRefreshFailure.staleAccountIdentity + } + } + + private func requireRateLimitRuntimeIdentity( + _ snapshot: CodexReviewBackendModel.Auth.Snapshot, + authorization: RuntimeAccountObservationAuthorization + ) throws { + guard runtimeAccountObservation(from: snapshot) == authorization.observation else { + throw ActiveRateLimitRefreshFailure.staleAccountIdentity + } + } + private func refreshSavedAccountRateLimits(for account: CodexReviewAccount) async { - let temporaryCodexHomeURL = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-review-rate-limits-\(UUID().uuidString)", isDirectory: true) + let temporaryCodexHomeURL: URL + do { + temporaryCodexHomeURL = try await accountRegistry.reserveTemporaryCodexHome( + kind: .rateLimits + ) + } catch { + account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) + _ = await persistRateLimitMetadata(for: account) + return + } + var isolatedAppServer: CodexAppServer? do { - guard try CodexReviewAccountRegistry.copySavedAuth( + guard try await accountRegistry.copySavedAuth( accountKey: account.accountKey, - from: codexHomeURL, to: temporaryCodexHomeURL ) else { account.markRateLimitReauthenticationRequired( fetchedAt: Date(), error: "Saved account authentication is not available." ) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL - ) + _ = await persistRateLimitMetadata(for: account) + await accountRegistry.finishTemporaryCodexHome(temporaryCodexHomeURL) return } let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) - let didRefresh = await refreshRateLimits(for: account, using: runtime.backend, source: "saved-auth-isolated-runtime") - do { - if didRefresh { - try CodexReviewAccountRegistry.saveSharedAuth( - from: temporaryCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL - ) - } - } catch { - await closeIsolatedLoginRuntime(appServer: runtime.appServer, codexHomeURL: temporaryCodexHomeURL) - throw error + isolatedAppServer = runtime.appServer + let didRefresh = await refreshRateLimits( + for: account, + using: runtime.backend, + validatesBackendAccount: true + ) + if didRefresh { + try await accountRegistry.saveSharedAuth( + from: temporaryCodexHomeURL, + for: savedAccountPayload(from: account) + ) } await closeIsolatedLoginRuntime(appServer: runtime.appServer, codexHomeURL: temporaryCodexHomeURL) } catch { - try? FileManager.default.removeItem(at: temporaryCodexHomeURL) - account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL + await closeIsolatedLoginRuntime( + appServer: isolatedAppServer, + codexHomeURL: temporaryCodexHomeURL ) + account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) + _ = await persistRateLimitMetadata(for: account) } } private func refreshRateLimits( for account: CodexReviewAccount, using backend: AppServerCodexReviewBackend?, - source: String + validatesBackendAccount: Bool ) async -> Bool { do { guard let backend else { return false } - if source == "saved-auth-isolated-runtime" { + if validatesBackendAccount { try await validateRateLimitBackendAccount( account, using: backend @@ -1497,17 +3591,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let response = try await backend.readRateLimits() applyRateLimits(response, to: account) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL - ) - return true + return await persistRateLimitMetadata(for: account) } catch { recordRateLimitRefreshFailure(error, account: account) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL - ) + _ = await persistRateLimitMetadata(for: account) return false } } @@ -1546,40 +3633,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func closeIsolatedLoginRuntime(appServer: CodexAppServer?, codexHomeURL: URL?) async { guard let codexHomeURL else { - await appServer?.close() + if let appServer { + await appServerCloser(appServer) + } return } guard codexHomeURL != self.codexHomeURL else { return } - await appServer?.close() - try? FileManager.default.removeItem(at: codexHomeURL) - } - - private func takeLoginRuntimeForCleanup() -> PendingLoginRuntimeCleanup { - loginChallenge = nil - loginBackend = nil - isWaitingForLoginAccountUpdate = false - let loginAppServer = loginAppServer - self.loginAppServer = nil - let loginCodexHomeURL = loginCodexHomeURL - self.loginCodexHomeURL = nil - let activeAuthenticationSession = activeAuthenticationSession - self.activeAuthenticationSession = nil - authenticationTask?.cancel() - authenticationTask = nil - loginNotificationTask?.cancel() - loginNotificationTask = nil - return .init( - appServer: loginAppServer, - codexHomeURL: loginCodexHomeURL, - authenticationSession: activeAuthenticationSession - ) - } - - private func cleanupLoginRuntime(_ cleanup: PendingLoginRuntimeCleanup) async { - await cleanup.authenticationSession?.cancel() - await closeIsolatedLoginRuntime(appServer: cleanup.appServer, codexHomeURL: cleanup.codexHomeURL) + if let appServer { + await appServerCloser(appServer) + } + await accountRegistry.finishTemporaryCodexHome(codexHomeURL) } private func applyRateLimits( @@ -1601,18 +3666,45 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func persistRefreshedSharedAuth( from sourceCodexHomeURL: URL?, - for account: CodexReviewAccount - ) { + for account: CodexReviewAccount, + runtimeAuthorization: AccountRegistryStore.RuntimeCommitAuthorization? = nil + ) async throws { guard let sourceCodexHomeURL else { return } - try? CodexReviewAccountRegistry.saveSharedAuth( + try await accountRegistry.saveSharedAuth( from: sourceCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL + for: savedAccountPayload(from: account), + runtimeAuthorization: runtimeAuthorization ) } + @discardableResult + private func persistRateLimitMetadata( + for account: CodexReviewAccount, + runtimeAuthorization: AccountRegistryStore.RuntimeCommitAuthorization? = nil + ) async -> Bool { + do { + try await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account), + runtimeAuthorization: runtimeAuthorization + ) + return true + } catch { + if let runtimeAuthorization, + (try? requireRuntimeCommitAdmission( + generation: runtimeAuthorization.generation + )) == nil { + logger.debug("Dropping rate-limit persistence from a retired runtime generation") + return false + } + let message = "Failed to persist account rate-limit metadata: \(error.localizedDescription)" + account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: message) + logger.error("\(message, privacy: .public)") + return false + } + } + private func maskedReviewAccountEmail(_ email: String) -> String { let parts = email.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) guard parts.count == 2, @@ -1665,463 +3757,87 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } - private static func authenticationURL(from challenge: CodexReviewBackendModel.Login.Challenge) throws -> URL { - guard let url = challenge.verificationURL else { - throw CodexReviewAPI.Error.io("Authentication did not provide a valid authorization URL.") - } - return url - } -} - -@MainActor -private struct AppServerRuntime: Sendable { - var appServer: CodexAppServer - var modelContainer: CodexModelContainer - var backend: AppServerCodexReviewBackend -} - -@MainActor -private struct LoginRuntime: Sendable { - var appServer: CodexAppServer - var backend: AppServerCodexReviewBackend - var codexHomeURL: URL - var usesPrimaryRuntime: Bool -} - -private enum LoginActivation: Equatable, Sendable { - case activateAuthenticatedAccount - case preserveActiveAccount(String?) - - func resolvedActiveAccountKey( - authenticatedAccountKey: String, - persistedAccounts: [CodexReviewAccount] - ) -> String? { - switch self { - case .activateAuthenticatedAccount: - return authenticatedAccountKey - case .preserveActiveAccount(let activeAccountKey): - return activeAccountKey.flatMap { activeAccountKey in - persistedAccounts.contains(where: { $0.accountKey == activeAccountKey }) - ? activeAccountKey - : nil - } - } - } -} - -private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async throws -> AppServerRuntime - -@MainActor -private enum CodexReviewAccountRegistry { - private struct Registry: Codable { - var activeAccountKey: String? - var accounts: [Entry] - } - - private struct Entry: Codable { - var accountKey: String? - var kind: Kind - var email: String - var planType: String? - var lastActivatedAt: Date? - var lastRateLimitFetchAt: Date? - var lastRateLimitError: String? - var cachedRateLimits: [SavedRateLimitWindow]? - - enum CodingKeys: String, CodingKey { - case accountKey - case kind - case email - case planType - case lastActivatedAt - case lastRateLimitFetchAt - case lastRateLimitError - case cachedRateLimits - } - - init( - accountKey: String?, - kind: Kind, - email: String, - planType: String?, - lastActivatedAt: Date?, - lastRateLimitFetchAt: Date?, - lastRateLimitError: String?, - cachedRateLimits: [SavedRateLimitWindow]? - ) { - self.accountKey = accountKey - self.kind = kind - self.email = email - self.planType = planType - self.lastActivatedAt = lastActivatedAt - self.lastRateLimitFetchAt = lastRateLimitFetchAt - self.lastRateLimitError = lastRateLimitError - self.cachedRateLimits = cachedRateLimits - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.accountKey = try container.decodeIfPresent(String.self, forKey: .accountKey) - self.email = try container.decode(String.self, forKey: .email) - // Registries written before the kind field existed must keep - // decoding; dropping them would empty the persisted account list. - self.kind = try container.decodeIfPresent(Kind.self, forKey: .kind) - ?? Kind.legacyDefault(accountKey: accountKey, email: email) - self.planType = try container.decodeIfPresent(String.self, forKey: .planType) - self.lastActivatedAt = try container.decodeIfPresent(Date.self, forKey: .lastActivatedAt) - self.lastRateLimitFetchAt = try container.decodeIfPresent(Date.self, forKey: .lastRateLimitFetchAt) - self.lastRateLimitError = try container.decodeIfPresent(String.self, forKey: .lastRateLimitError) - self.cachedRateLimits = try container.decodeIfPresent( - [SavedRateLimitWindow].self, - forKey: .cachedRateLimits - ) - } - } - - private enum Kind: String, Codable { - case chatGPT = "chatgpt" - case apiKey - case amazonBedrock - - static func legacyDefault(accountKey: String?, email: String) -> Self { - let normalizedAccountKey = accountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { $0.isEmpty ? nil : $0 } - switch normalizedAccountKey ?? CodexReviewAccount.normalizedEmail(email) { - case "api-key": - return .apiKey - case "amazon-bedrock": - return .amazonBedrock - default: - return .chatGPT - } - } - - init(_ accountKind: CodexReviewBackendModel.Account.Kind) { - switch accountKind { - case .chatGPT: - self = .chatGPT - case .apiKey: - self = .apiKey - case .amazonBedrock: - self = .amazonBedrock - } - } - - var accountKind: CodexReviewBackendModel.Account.Kind { - switch self { - case .chatGPT: - .chatGPT - case .apiKey: - .apiKey - case .amazonBedrock: - .amazonBedrock + private func validateRuntimeAccount( + _ snapshot: CodexReviewBackendModel.Auth.Snapshot, + expected: ExpectedRuntimeAccount + ) throws { + let activeAccount = snapshot.activeAccountID.flatMap { activeID in + snapshot.accounts.first { $0.id == activeID } + } + switch expected { + case .signedOut: + guard activeAccount == nil else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The replacement runtime remained authenticated after a signed-out account transition." + ) } - } - - } - - private struct SavedRateLimitWindow: Codable { - var windowDurationMinutes: Int - var usedPercent: Int - var resetsAt: Date? - - var tuple: (windowDurationMinutes: Int, usedPercent: Int, resetsAt: Date?) { - (windowDurationMinutes, usedPercent, resetsAt) - } - } - - static func load(codexHomeURL: URL) -> (accounts: [CodexReviewAccount], activeAccountKey: String?) { - let registry = loadRegistry(codexHomeURL: codexHomeURL) - let accounts = registry.accounts.compactMap(makeAccount(from:)) - let activeAccountKey = registry.activeAccountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { activeAccountKey in - accounts.contains(where: { $0.accountKey == activeAccountKey }) ? activeAccountKey : nil + case .account(let expectedAccountKey): + guard let activeAccount, + activeAccount.kind == .chatGPT, + CodexReviewAccount.normalizedEmail(activeAccount.id.rawValue) + == CodexReviewAccount.normalizedEmail(expectedAccountKey) else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The replacement runtime did not authenticate the expected account \(expectedAccountKey)." + ) } - logger.info("Loaded \(accounts.count, privacy: .public) persisted Codex review account(s)") - return (accounts, activeAccountKey) - } - - static func saveAccounts( - _ accounts: [CodexReviewAccount], - activeAccountKey: String?, - codexHomeURL: URL - ) throws { - let existing = loadRegistry(codexHomeURL: codexHomeURL) - let existingByAccountKey = Dictionary(uniqueKeysWithValues: existing.accounts.compactMap { entry in - normalizedAccountKey(from: entry).map { ($0, entry) } - }) - let normalizedActiveAccountKey = activeAccountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { accountKey in - accounts.contains(where: { $0.accountKey == accountKey }) ? accountKey : nil + case .observedAccount(let expectedAccountKey, let provider): + guard let activeAccount, + activeAccount.kind == provider.accountKind, + CodexReviewAccount.normalizedEmail(activeAccount.id.rawValue) + == CodexReviewAccount.normalizedEmail(expectedAccountKey) else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The replacement runtime did not authenticate the observed account \(expectedAccountKey)." + ) } - let records = accounts.map { account in - var entry = existingByAccountKey[account.accountKey] ?? Entry( - accountKey: account.accountKey, - kind: .init(account.kind), - email: account.email, - planType: account.planType, - lastActivatedAt: nil, - lastRateLimitFetchAt: nil, - lastRateLimitError: nil, - cachedRateLimits: nil - ) - entry.accountKey = account.accountKey - entry.kind = .init(account.kind) - entry.email = account.email - entry.planType = account.planType - entry.cachedRateLimits = account.rateLimits.map { window in - .init( - windowDurationMinutes: window.windowDurationMinutes, - usedPercent: window.usedPercent, - resetsAt: window.resetsAt + case .anyChatGPT: + guard activeAccount?.kind == .chatGPT else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The replacement runtime did not confirm the committed ChatGPT authentication." ) } - entry.lastRateLimitFetchAt = account.lastRateLimitFetchAt - entry.lastRateLimitError = account.lastRateLimitError - if account.accountKey == normalizedActiveAccountKey { - entry.lastActivatedAt = Date() + case .cancelOutcomeUnknown: + guard snapshot.activeAccountID == nil || activeAccount?.kind == .chatGPT else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "An unknown login cancellation outcome resolved to an unsupported authentication provider." + ) } - return entry - } - try saveRegistry( - .init(activeAccountKey: normalizedActiveAccountKey, accounts: records), - codexHomeURL: codexHomeURL - ) - } - - static func activateAccount( - _ accountKey: String, - accounts: [CodexReviewAccount], - codexHomeURL: URL - ) throws { - let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) - let savedAuthURL = savedAccountAuthURL( - accountKey: normalizedAccountKey, - codexHomeURL: codexHomeURL - ) - guard FileManager.default.fileExists(atPath: savedAuthURL.path) else { - throw CodexReviewAPI.Error.io("Saved authentication is missing for account \(normalizedAccountKey).") - } - try saveAccounts( - accounts, - activeAccountKey: normalizedAccountKey, - codexHomeURL: codexHomeURL - ) - try copyAuth(from: savedAuthURL, to: sharedAuthURL(codexHomeURL: codexHomeURL)) - } - - static func updateCachedRateLimits( - from account: CodexReviewAccount, - codexHomeURL: URL - ) throws { - var registry = loadRegistry(codexHomeURL: codexHomeURL) - guard let index = registry.accounts.firstIndex(where: { - normalizedAccountKey(from: $0) == account.accountKey - }) else { - return - } - registry.accounts[index].planType = account.planType - registry.accounts[index].cachedRateLimits = account.rateLimits.map { window in - .init( - windowDurationMinutes: window.windowDurationMinutes, - usedPercent: window.usedPercent, - resetsAt: window.resetsAt - ) - } - registry.accounts[index].lastRateLimitFetchAt = account.lastRateLimitFetchAt - registry.accounts[index].lastRateLimitError = account.lastRateLimitError - try saveRegistry(registry, codexHomeURL: codexHomeURL) - } - - static func saveSharedAuth( - for account: CodexReviewAccount, - codexHomeURL: URL - ) throws { - try saveSharedAuth( - from: codexHomeURL, - for: account, - codexHomeURL: codexHomeURL - ) - } - - static func saveSharedAuth( - from sourceCodexHomeURL: URL, - for account: CodexReviewAccount, - codexHomeURL: URL - ) throws { - let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL) - guard FileManager.default.fileExists(atPath: sourceURL.path) else { - return - } - try copyAuth( - from: sourceURL, - to: savedAccountAuthURL(accountKey: account.accountKey, codexHomeURL: codexHomeURL) - ) - } - - static func removeSharedAuth(codexHomeURL: URL) throws { - let url = sharedAuthURL(codexHomeURL: codexHomeURL) - guard FileManager.default.fileExists(atPath: url.path) else { - return - } - try FileManager.default.removeItem(at: url) - } - - static func removeSavedAccountDirectory( - accountKey: String, - codexHomeURL: URL - ) throws { - let directoryURL = savedAccountDirectoryURL(accountKey: accountKey, codexHomeURL: codexHomeURL) - guard FileManager.default.fileExists(atPath: directoryURL.path) else { - return - } - try FileManager.default.removeItem(at: directoryURL) - } - - static func copySavedAuth( - accountKey: String, - from sourceCodexHomeURL: URL, - to destinationCodexHomeURL: URL - ) throws -> Bool { - let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) - let sourceURL = savedAccountAuthURL( - accountKey: normalizedAccountKey, - codexHomeURL: sourceCodexHomeURL - ) - guard FileManager.default.fileExists(atPath: sourceURL.path) else { - return false + case .reconcileCurrentRuntime: + break } - try copyAuth( - from: sourceURL, - to: sharedAuthURL(codexHomeURL: destinationCodexHomeURL) - ) - return true } - private static func makeAccount(from entry: Entry) -> CodexReviewAccount? { - let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) - guard email.isEmpty == false else { - return nil + private func runtimeAccountObservation( + from snapshot: CodexReviewBackendModel.Auth.Snapshot + ) -> RuntimeAccountObservation { + guard let activeAccountID = snapshot.activeAccountID else { + return .signedOut } - let normalizedEmail = CodexReviewAccount.normalizedEmail(email) - let accountKey = entry.accountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { $0.isEmpty ? nil : $0 } - ?? normalizedEmail - let account = CodexReviewAccount( - accountKey: accountKey, - email: email, - planType: entry.planType, - kind: entry.kind.accountKind - ) - account.updateRateLimits(entry.cachedRateLimits?.map(\.tuple) ?? []) - account.updateRateLimitFetchMetadata( - fetchedAt: entry.lastRateLimitFetchAt, - error: entry.lastRateLimitError - ) - return account - } - - private static func loadRegistry(codexHomeURL: URL) -> Registry { - let url = registryURL(codexHomeURL: codexHomeURL) - guard let data = try? Data(contentsOf: url), - let registry = try? JSONDecoder().decode(Registry.self, from: data) - else { - return .init(activeAccountKey: nil, accounts: []) + guard let backendAccount = snapshot.accounts.first(where: { $0.id == activeAccountID }), + let account = Self.monitorAccount(from: backendAccount) else { + return .invalid } - return registry - } - - private static func saveRegistry( - _ registry: Registry, - codexHomeURL: URL - ) throws { - let url = registryURL(codexHomeURL: codexHomeURL) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true + return .account( + accountKey: account.accountKey, + provider: .init(account.kind) ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try encoder.encode(registry).write(to: url, options: .atomic) } - private static func copyAuth(from sourceURL: URL, to destinationURL: URL) throws { - let destinationDirectoryURL = destinationURL.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: destinationDirectoryURL, - withIntermediateDirectories: true - ) - let replacementURL = destinationDirectoryURL - .appendingPathComponent(".\(destinationURL.lastPathComponent).replacement-\(UUID().uuidString)") - try FileManager.default.copyItem(at: sourceURL, to: replacementURL) - do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - _ = try FileManager.default.replaceItemAt( - destinationURL, - withItemAt: replacementURL, - backupItemName: nil, - options: [] - ) - } else { - try FileManager.default.moveItem(at: replacementURL, to: destinationURL) - } - } catch { - try? FileManager.default.removeItem(at: replacementURL) - throw error + private func shouldReconcileRuntimeAuthSnapshot( + expectation: ExpectedRuntimeAccount?, + observation: RuntimeAccountObservation + ) -> Bool { + guard case .cancelOutcomeUnknown(let previousActiveAccountKey) = expectation else { + return true } - } - - private static func normalizedAccountKey(from entry: Entry) -> String? { - let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedEmail = CodexReviewAccount.normalizedEmail(email) - return entry.accountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { $0.isEmpty ? nil : $0 } - ?? (normalizedEmail.isEmpty ? nil : normalizedEmail) - } - - private static func registryURL(codexHomeURL: URL) -> URL { - accountsDirectoryURL(codexHomeURL: codexHomeURL) - .appendingPathComponent("registry.json") - } - - private static func sharedAuthURL(codexHomeURL: URL) -> URL { - codexHomeURL.appendingPathComponent("auth.json") - } - - private static func savedAccountAuthURL(accountKey: String, codexHomeURL: URL) -> URL { - savedAccountDirectoryURL(accountKey: accountKey, codexHomeURL: codexHomeURL) - .appendingPathComponent("auth.json") - } - - private static func savedAccountDirectoryURL(accountKey: String, codexHomeURL: URL) -> URL { - accountsDirectoryURL(codexHomeURL: codexHomeURL) - .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) - } - - private static func accountsDirectoryURL(codexHomeURL: URL) -> URL { - codexHomeURL.appendingPathComponent("accounts", isDirectory: true) - } - - private static func pathComponent(forAccountKey accountKey: String) -> String { - let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) - switch normalizedAccountKey { - case ".": - return "%2E" - case "..": - return "%2E%2E" - default: - break + switch observation { + case .signedOut: + return previousActiveAccountKey != nil + case .account: + return true + case .invalid: + return true } - return normalizedAccountKey - .addingPercentEncoding(withAllowedCharacters: accountDirectoryNameAllowedCharacters) - ?? normalizedAccountKey } - - private static let accountDirectoryNameAllowedCharacters = - CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) } + +private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async throws -> AppServerRuntime diff --git a/Sources/CodexReviewHost/LoginSession.swift b/Sources/CodexReviewHost/LoginSession.swift new file mode 100644 index 00000000..a5b23f1a --- /dev/null +++ b/Sources/CodexReviewHost/LoginSession.swift @@ -0,0 +1,497 @@ +import Foundation +import CodexAppServerKit +import CodexReviewKit +import CodexReviewAppServer + +struct LoginRuntime: Sendable { + let appServer: CodexAppServer + let backend: AppServerCodexReviewBackend + let codexHomeURL: URL + let usesPrimaryRuntime: Bool +} + +enum LoginRequest: Sendable { + case signIn + case addAccount +} + +enum LoginPurpose: Equatable, Sendable { + case signIn + case addAccountPreservingActive + + var activation: LoginActivation { + switch self { + case .signIn: + return .activateAuthenticatedAccount + case .addAccountPreservingActive: + return .preserveActiveAccount + } + } +} + +enum LoginActivation: Equatable, Sendable { + case activateAuthenticatedAccount + case preserveActiveAccount +} + +enum LoginRootObservation: Sendable { + case outcome(CodexLoginOutcome) + case failure(CodexReviewAuthenticationFailure) + case waiterCancelled(message: String?) +} + +enum LoginTerminationReason: Equatable, Sendable { + case rootOutcome + case explicitCancellation + case urlOpenFailure(CodexReviewAuthenticationFailure) + case runtimeFailure(CodexReviewAuthenticationFailure) + case storeStop + + var requestsSDKCancellation: Bool { + switch self { + case .rootOutcome: + return false + case .explicitCancellation, .urlOpenFailure, .runtimeFailure, .storeStop: + return true + } + } +} + +enum PrimaryAuthenticationReconciliationResult: Equatable, Sendable { + case authenticated(accountKey: String) + case cancelled + case committedNeedsRuntimeReconciliation(message: String) +} + +enum PrimaryAuthenticationReconciliationCause: Sendable { + case committed(CodexLoginReconciliationReason) + case cancelOutcomeUnknown(previousActiveAccountKey: String?) +} + +@MainActor +final class LoginFinalResultCompletion: Sendable { + private var result: PrimaryAuthenticationReconciliationResult? + private var waiters: [CheckedContinuation] = [] + + func wait() async -> PrimaryAuthenticationReconciliationResult { + if let result { + return result + } + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + @discardableResult + func resolve(_ result: PrimaryAuthenticationReconciliationResult) -> Bool { + guard self.result == nil else { + return false + } + self.result = result + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(returning: result) + } + return true + } +} + +struct PrimaryAuthenticationReconciliationHandoff: Sendable { + let loginGenerationID: UUID + let mutationLease: AccountRegistryStore.MutationLease + let cause: PrimaryAuthenticationReconciliationCause + let finalResult: LoginFinalResultCompletion +} + +enum LoginSessionTerminal: Sendable { + case succeeded + case failed(CodexReviewAuthenticationFailure) + case cancelled + case stopped + case committedNeedsRuntimeReconciliation(message: String) + case primaryRuntimeReconciliation(PrimaryAuthenticationReconciliationHandoff) +} + +actor LoginStartCompletion { + private var result: Result? + private var waiters: [CheckedContinuation, Never>] = [] + + func wait() async -> Result { + if let result { + return result + } + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func resolve(_ result: Result) { + guard self.result == nil else { + return + } + self.result = result + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(returning: result) + } + } +} + +actor LoginOperationState { + enum BindDisposition: Sendable { + case proceed + case cancel + } + + enum PreCommitFailureDisposition: Sendable { + case fail + case cancel + } + + private enum Phase { + case acquiringRuntime + case runtimeBound(LoginRuntime) + case loginPending(LoginRuntime, CodexLoginHandle) + case resourcesTaken + } + + private var phase: Phase = .acquiringRuntime + private var cancellationRequested = false + private var cancellationClaimed = false + private var preCommitFailureClaimed = false + private var urlPresentationClaimed = false + + func requestCancellation() -> CodexLoginHandle? { + if preCommitFailureClaimed == false { + cancellationRequested = true + } + guard preCommitFailureClaimed == false else { + return nil + } + guard cancellationClaimed == false else { + return nil + } + guard case .loginPending(_, let handle) = phase else { + return nil + } + cancellationClaimed = true + return handle + } + + func recordCancellationIntent() { + guard preCommitFailureClaimed == false else { + return + } + cancellationRequested = true + } + + func claimPreCommitFailure() -> PreCommitFailureDisposition { + guard preCommitFailureClaimed == false else { + preconditionFailure("A login pre-commit failure can be claimed only once.") + } + guard cancellationRequested == false else { + return .cancel + } + preCommitFailureClaimed = true + return .fail + } + + func bind(runtime: LoginRuntime) -> BindDisposition { + guard case .acquiringRuntime = phase else { + preconditionFailure("A login runtime can be bound only once.") + } + phase = .runtimeBound(runtime) + return cancellationRequested ? .cancel : .proceed + } + + func bind(handle: CodexLoginHandle, runtime: LoginRuntime) -> BindDisposition { + guard case .runtimeBound(let boundRuntime) = phase, + boundRuntime.appServer === runtime.appServer else { + preconditionFailure("A login handle must bind to its reserved runtime exactly once.") + } + phase = .loginPending(runtime, handle) + guard cancellationRequested else { + return .proceed + } + cancellationClaimed = true + return .cancel + } + + func claimURLPresentation(handle: CodexLoginHandle) -> BindDisposition { + guard case .loginPending(_, let boundHandle) = phase, + boundHandle == handle else { + preconditionFailure("A login URL can be presented only for the bound login handle.") + } + precondition(urlPresentationClaimed == false, "A login URL can be claimed for presentation only once.") + guard cancellationRequested == false else { + return .cancel + } + urlPresentationClaimed = true + return .proceed + } + + func runtime() -> LoginRuntime? { + switch phase { + case .acquiringRuntime, .resourcesTaken: + return nil + case .runtimeBound(let runtime), .loginPending(let runtime, _): + return runtime + } + } + + func handle() -> CodexLoginHandle? { + guard case .loginPending(_, let handle) = phase else { + return nil + } + return handle + } + + func takeOwnedRuntime() -> LoginRuntime? { + switch phase { + case .runtimeBound(let runtime), .loginPending(let runtime, _): + guard runtime.usesPrimaryRuntime == false else { + return nil + } + phase = .resourcesTaken + return runtime + case .acquiringRuntime, .resourcesTaken: + return nil + } + } +} + +@MainActor +final class LoginSession { + typealias RootOperation = @MainActor @Sendable ( + LoginOperationState, + LoginStartCompletion + ) async -> LoginRootObservation + typealias TerminationHandler = @MainActor @Sendable ( + LoginSession, + LoginTerminationReason, + LoginRootObservation + ) async -> LoginSessionTerminal + + private enum State { + case initialized + case active + case closing( + reason: LoginTerminationReason, + completion: Task + ) + case closed(LoginSessionTerminal) + } + + let generationID: UUID + let purpose: LoginPurpose + let previousActiveAccountKey: String? + private let mutationLease: AccountRegistryStore.MutationLease + private let operationState = LoginOperationState() + private let startCompletion = LoginStartCompletion() + private let rootOperation: RootOperation + private let terminationHandler: TerminationHandler + private let cancellationTimeout: Duration + private var rootTask: Task? + private var state: State = .initialized + private var didReleaseMutationLease = false + private var primaryAuthenticationFinalResult: LoginFinalResultCompletion? + private var didRoutePrimaryAuthenticationHandoff = false + + init( + generationID: UUID, + purpose: LoginPurpose, + previousActiveAccountKey: String?, + mutationLease: AccountRegistryStore.MutationLease, + cancellationTimeout: Duration = .seconds(5), + rootOperation: @escaping RootOperation, + terminationHandler: @escaping TerminationHandler + ) { + self.generationID = generationID + self.purpose = purpose + self.previousActiveAccountKey = previousActiveAccountKey + self.mutationLease = mutationLease + self.cancellationTimeout = cancellationTimeout + self.rootOperation = rootOperation + self.terminationHandler = terminationHandler + } + + func activate() async -> Result { + guard case .initialized = state else { + preconditionFailure("A login session root task can be activated only once.") + } + let operationState = operationState + let startCompletion = startCompletion + let rootOperation = rootOperation + rootTask = Task { @MainActor in + await rootOperation(operationState, startCompletion) + } + state = .active + return await startCompletion.wait() + } + + func publishRootObservation(_: LoginRootObservation) { + guard case .active = state else { + return + } + _ = beginClosing(reason: .rootOutcome) + } + + func terminate(reason: LoginTerminationReason) async -> LoginSessionTerminal { + switch state { + case .initialized: + preconditionFailure("A login session must be activated before termination.") + case .active: + return await beginClosing(reason: reason).value + case .closing(_, let completion): + if reason.requestsSDKCancellation { + await operationState.recordCancellationIntent() + } + return await completion.value + case .closed(let terminal): + return terminal + } + } + + func runtime() async -> LoginRuntime? { + await operationState.runtime() + } + + func handle() async -> CodexLoginHandle? { + await operationState.handle() + } + + func takeOwnedRuntimeForClose() async -> LoginRuntime? { + await operationState.takeOwnedRuntime() + } + + func takeMutationLeaseForRelease() -> AccountRegistryStore.MutationLease? { + guard didReleaseMutationLease == false else { + return nil + } + didReleaseMutationLease = true + return mutationLease + } + + func mutationLeaseForOwnedOperation() -> AccountRegistryStore.MutationLease { + precondition( + didReleaseMutationLease == false, + "A login session cannot authorize registry work after releasing its mutation lease." + ) + return mutationLease + } + + func mutationLeaseForCancellation() -> AccountRegistryStore.MutationLease? { + didReleaseMutationLease ? nil : mutationLease + } + + func recordCancellationIntent() async { + await operationState.recordCancellationIntent() + } + + func claimPreCommitFailure() async -> LoginOperationState.PreCommitFailureDisposition { + await operationState.claimPreCommitFailure() + } + + func waitForPrimaryAuthenticationFinalResult() async -> PrimaryAuthenticationReconciliationResult { + guard let primaryAuthenticationFinalResult else { + preconditionFailure("Only a handed-off primary authentication can expose a deferred final result.") + } + return await primaryAuthenticationFinalResult.wait() + } + + func takePrimaryAuthenticationReconciliationHandoff( + cause: PrimaryAuthenticationReconciliationCause + ) -> PrimaryAuthenticationReconciliationHandoff { + guard let mutationLease = takeMutationLeaseForRelease() else { + preconditionFailure("A primary authentication reconciliation lease can be handed off only once.") + } + precondition( + primaryAuthenticationFinalResult == nil, + "A login session can install its primary authentication final-result completion only once." + ) + let finalResult = LoginFinalResultCompletion() + primaryAuthenticationFinalResult = finalResult + return .init( + loginGenerationID: generationID, + mutationLease: mutationLease, + cause: cause, + finalResult: finalResult + ) + } + + func claimPrimaryAuthenticationHandoffForDirectReconciliation( + _ handoff: PrimaryAuthenticationReconciliationHandoff + ) { + precondition(handoff.loginGenerationID == generationID) + precondition( + didRoutePrimaryAuthenticationHandoff == false, + "A primary authentication handoff can have only one reconciliation route." + ) + didRoutePrimaryAuthenticationHandoff = true + } + + func takePrimaryAuthenticationHandoffForRuntimeStop( + from terminal: LoginSessionTerminal + ) -> PrimaryAuthenticationReconciliationHandoff? { + guard case .primaryRuntimeReconciliation(let handoff) = terminal else { + return nil + } + guard didRoutePrimaryAuthenticationHandoff == false else { + return nil + } + didRoutePrimaryAuthenticationHandoff = true + return handoff + } + + private func beginClosing( + reason: LoginTerminationReason + ) -> Task { + guard case .active = state else { + preconditionFailure("Only an active login session can begin termination.") + } + guard rootTask != nil else { + preconditionFailure("A login session must own its root task through termination.") + } + let completion = Task { @MainActor [weak self] in + guard let self else { + return LoginSessionTerminal.stopped + } + return await self.performTermination(reason: reason) + } + state = .closing(reason: reason, completion: completion) + return completion + } + + private func performTermination( + reason: LoginTerminationReason + ) async -> LoginSessionTerminal { + guard let rootTask else { + preconditionFailure("A login session must own its root task through termination.") + } + var cancellationFailureMessage: String? + if reason.requestsSDKCancellation, + let handle = await operationState.requestCancellation() { + do { + _ = try await handle.cancel(acknowledgementTimeout: cancellationTimeout) + } catch { + cancellationFailureMessage = error.localizedDescription + rootTask.cancel() + } + } + + var observation = await rootTask.value + if case .waiterCancelled = observation, + let cancellationFailureMessage { + observation = .waiterCancelled(message: cancellationFailureMessage) + } + let terminal = await terminationHandler(self, reason, observation) + state = .closed(terminal) + return terminal + } + + isolated deinit { + rootTask?.cancel() + } +} diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift deleted file mode 100644 index 6ddb464b..00000000 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ /dev/null @@ -1,212 +0,0 @@ -import Foundation - -package struct ReviewBackendEventSessionMetrics: Equatable, Sendable { - package var routed = 0 - package var decoded = 0 - package var emitted = 0 - package var ignored = 0 - package var buffered = 0 - package var commandTimeoutWarnings = 0 - package var firstEventLatencyMs: Int? - package var terminalLatencyMs: Int? - - package init() {} -} - -package struct ReviewBackendEventSessionCallbacks: Sendable { - package var recordTurnStarted: @Sendable (_ turnID: String) async -> Void - package var recordFinished: - @Sendable ( - _ run: CodexReviewBackendModel.Review.Run, - _ metrics: ReviewBackendEventSessionMetrics - ) async -> Void - - package init( - recordTurnStarted: @escaping @Sendable (_ turnID: String) async -> Void = { _ in }, - recordFinished: - @escaping @Sendable ( - _ run: CodexReviewBackendModel.Review.Run, - _ metrics: ReviewBackendEventSessionMetrics - ) async -> Void = { _, _ in } - ) { - self.recordTurnStarted = recordTurnStarted - self.recordFinished = recordFinished - } -} - -package actor ReviewBackendEventSession { - private var run: CodexReviewBackendModel.Review.Run - private let mailbox: BackendReviewEventMailbox - private let callbacks: ReviewBackendEventSessionCallbacks - private var reviewThreadIDsForCleanup: [String] = [] - private var cancellationRequestedMessage: String? - private let createdAt = Date() - private var finished = false - private var metrics = ReviewBackendEventSessionMetrics() - - package init( - run: CodexReviewBackendModel.Review.Run, - mailbox: BackendReviewEventMailbox = .init(), - callbacks: ReviewBackendEventSessionCallbacks = .init() - ) { - self.run = run - self.mailbox = mailbox - self.callbacks = callbacks - if let reviewThreadID = run.reviewThreadID?.nilIfEmpty, - reviewThreadID != run.threadID - { - self.reviewThreadIDsForCleanup.append(reviewThreadID) - } - } - - package func updateRun(_ run: CodexReviewBackendModel.Review.Run) { - self.run = run - noteReviewThreadIDForCleanup(run.reviewThreadID) - } - - package func currentRun() -> CodexReviewBackendModel.Review.Run { - run - } - - package func attempt() -> BackendReviewAttempt { - .init(run: run, events: mailbox) - } - - package func cleanupThreadIDs() -> [String] { - var threadIDs = reviewThreadIDsForCleanup.filter { $0 != run.threadID } - threadIDs.append(run.threadID) - return threadIDs - } - - package func requestCancellation(message: String) { - cancellationRequestedMessage = message - } - - package func clearCancellationRequest() { - cancellationRequestedMessage = nil - } - - package func finish(cancellationMessage: String?) async { - cancellationRequestedMessage = cancellationMessage - await finishSession(cancellationMessage: cancellationMessage) - } - - package func finish(throwing error: (any Error)?) async { - guard finished == false else { - return - } - if let error { - finished = true - await mailbox.fail(error) - } else { - finished = true - await mailbox.finish() - } - } - - package func abandon() async { - guard finished == false else { - return - } - finished = true - await mailbox.abandon() - } - - package func metricsSnapshot() -> ReviewBackendEventSessionMetrics { - metrics - } - - package func receive( - _ events: [CodexReviewBackendModel.Review.Event], - controlThreadID: String? = nil - ) async { - metrics.routed += 1 - guard finished == false else { - metrics.ignored += 1 - return - } - guard events.isEmpty == false else { - metrics.ignored += 1 - return - } - metrics.decoded += 1 - for event in events { - if await emit(event, controlThreadID: controlThreadID) { - return - } - } - } - - private func finishSession(cancellationMessage: String?) async { - guard finished == false else { - return - } - if let cancellationMessage { - _ = await emit(.cancelled(cancellationMessage)) - } else { - await mailbox.finish() - } - finished = true - } - - private func noteReviewThreadIDForCleanup(_ reviewThreadID: String?) { - guard let reviewThreadID = reviewThreadID?.nilIfEmpty, - reviewThreadID != run.threadID, - reviewThreadIDsForCleanup.contains(reviewThreadID) == false - else { - return - } - reviewThreadIDsForCleanup.append(reviewThreadID) - } - - private func emit( - _ event: CodexReviewBackendModel.Review.Event, - controlThreadID: String? = nil - ) async -> Bool { - noteEmission(event) - await mailbox.append(event) - await recordReviewEvent(event, controlThreadID: controlThreadID) - return event.isReviewBackendTerminal - } - - private func recordReviewEvent( - _ event: CodexReviewBackendModel.Review.Event, - controlThreadID _: String? = nil - ) async { - switch event { - case .started(let turnID, _, _): - await callbacks.recordTurnStarted(turnID) - case .completed, .failed, .cancelled: - await callbacks.recordFinished(run, metrics) - } - } - - private func noteEmission(_ event: CodexReviewBackendModel.Review.Event) { - metrics.emitted += 1 - if metrics.firstEventLatencyMs == nil { - metrics.firstEventLatencyMs = Self.durationMs(from: createdAt, to: Date()) - } - if event.isReviewBackendTerminal { - metrics.terminalLatencyMs = Self.durationMs(from: createdAt, to: Date()) - } - } - - private static func durationMs(from start: Date, to end: Date) -> Int { - let milliseconds = end.timeIntervalSince(start) * 1000 - guard milliseconds.isFinite else { - return 0 - } - return max(0, Int(milliseconds.rounded())) - } -} - -private extension CodexReviewBackendModel.Review.Event { - var isReviewBackendTerminal: Bool { - switch self { - case .completed, .failed, .cancelled: - true - case .started: - false - } - } -} diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index 065ad046..de86b7aa 100644 --- a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift +++ b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift @@ -1,5 +1,6 @@ import Foundation import ObservationBridge +import Synchronization @MainActor package enum ReviewObservationAwaiter { @@ -11,71 +12,90 @@ package enum ReviewObservationAwaiter { return true } - let waiter = ReviewTerminalObservationWaiter() - return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - waiter.begin( - run: run, - timeout: timeout, - continuation: continuation - ) - } - } onCancel: { - Task { @MainActor in - waiter.cancel() - } - } - } -} - -@MainActor -private final class ReviewTerminalObservationWaiter { - private var token: PortableObservationTracking.Token? - private var timeoutTask: Task? - private var continuation: CheckedContinuation? - private var isResolved = false - - func begin( - run: ReviewRunRecord, - timeout: Duration?, - continuation: CheckedContinuation - ) { - self.continuation = continuation - token = withPortableContinuousObservation { [weak self, run] event in - _ = run.core.lifecycle.status + let signal = ReviewTerminalObservationSignal() + let token = withPortableContinuousObservation { [run, signal] event in + _ = run.core.status guard run.isTerminal else { return } event.cancel() - self?.resolve(true) + signal.finish(true) + } + defer { + token.cancel() + signal.finish(false) + } + + guard let timeout else { + return await signal.wait() } - if let timeout { - timeoutTask = Task { @MainActor [weak self] in + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + await signal.wait() + } + group.addTask { do { try await Task.sleep(for: timeout) + return false } catch { - return + return false } - self?.resolve(false) } + let result = await group.next() ?? false + group.cancelAll() + return result } } +} - func cancel() { - resolve(false) +private final class ReviewTerminalObservationSignal: Sendable { + private enum State: Sendable { + case idle + case waiting(CheckedContinuation) + case finished(Bool) } - private func resolve(_ result: Bool) { - guard isResolved == false else { - return + private let state = Mutex(.idle) + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> Bool? in + switch state { + case .idle where Task.isCancelled: + state = .finished(false) + return false + case .idle: + state = .waiting(continuation) + return nil + case .waiting: + preconditionFailure("A terminal observation signal is single-consumer.") + case .finished(let result): + return result + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + finish(false) + } + } + + func finish(_ result: Bool) { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .finished(result) + return nil + case .waiting(let continuation): + state = .finished(result) + return continuation + case .finished: + return nil + } } - isResolved = true - timeoutTask?.cancel() - timeoutTask = nil - token?.cancel() - token = nil - let continuation = continuation - self.continuation = nil continuation?.resume(returning: result) } } diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index f891574e..0a11c4f4 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -6,193 +6,74 @@ package protocol CodexReviewBackend: Sendable { -> CodexReviewBackendModel.Settings.Snapshot func readAuth() async throws -> CodexReviewBackendModel.Auth.Snapshot - func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws - -> CodexReviewBackendModel.Login.Challenge - func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws func logout(_ account: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt - func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) + func interruptReview(_ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws - func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws + func prepareReviewRestart(_ attempt: ReviewAttempt) async throws -> CodexReviewBackendModel.Review.RestartToken func restartPreparedReview( _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt - func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async + func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] + func cleanupReview(_ attempt: ReviewAttempt) async + func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult } -package struct BackendReviewAttempt: Sendable { - package var run: CodexReviewBackendModel.Review.Run - package var events: BackendReviewEventMailbox +package struct ReviewRetainedThreadCleanupFailure: Codable, Equatable, Sendable { + package let threadID: ReviewThreadID + package let message: String - package init(run: CodexReviewBackendModel.Review.Run, events: BackendReviewEventMailbox = .init()) { - self.run = run - self.events = events + package init(threadID: ReviewThreadID, message: String) { + self.threadID = threadID + self.message = message } } -package actor BackendReviewEventMailbox { - private enum Terminal { - case finished - case cancelled - case failed(String) - } - - private enum Delivery { - case event(CodexReviewBackendModel.Review.Event) - case finished - case cancelled - case failed(String) - } - - private var bufferedEvents: [CodexReviewBackendModel.Review.Event] = [] - private var terminal: Terminal? - private var waiters: [UUID: CheckedContinuation] = [:] - - package init() {} - - package func next() async throws -> CodexReviewBackendModel.Review.Event? { - switch await nextDelivery() { - case .event(let event): - return event - case .finished: - return nil - case .cancelled: - throw CancellationError() - case .failed(let message): - throw BackendReviewEventMailboxError(message: message) - } - } - - package func append(_ event: CodexReviewBackendModel.Review.Event) { - guard terminal == nil else { - return - } - if let waiterID = waiters.keys.first, - let waiter = waiters.removeValue(forKey: waiterID) - { - waiter.resume(returning: .event(event)) - } else { - bufferedEvents.append(event) - } - if Self.isTerminal(event) { - terminal = .finished - resumeWaitersForTerminal() - } - } - - package func append(contentsOf events: [CodexReviewBackendModel.Review.Event]) { - for event in events { - append(event) - } - } - - package func finish() { - guard terminal == nil else { - return - } - terminal = .finished - resumeWaitersForTerminal() - } - - package func fail(_ error: any Error) { - guard terminal == nil else { - return - } - terminal = error is CancellationError ? .cancelled : .failed(error.localizedDescription) - resumeWaitersForTerminal() - } - - package func abandon() { - guard terminal == nil else { - return - } - terminal = .finished - bufferedEvents.removeAll(keepingCapacity: false) - resumeWaitersForTerminal() - } - - package func isFinished() -> Bool { - terminal != nil && bufferedEvents.isEmpty - } +package struct ReviewRetainedThreadCleanupResult: Codable, Equatable, Sendable { + package let failures: [ReviewRetainedThreadCleanupFailure] - private func nextDelivery() async -> Delivery { - if bufferedEvents.isEmpty == false { - let event = bufferedEvents.removeFirst() - resumeWaitersForTerminal() - return .event(event) - } - if let terminal { - return delivery(for: terminal) - } - let waiterID = UUID() - return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if bufferedEvents.isEmpty == false { - let event = bufferedEvents.removeFirst() - resumeWaitersForTerminal() - continuation.resume(returning: .event(event)) - } else if let terminal { - continuation.resume(returning: delivery(for: terminal)) - } else { - waiters[waiterID] = continuation - } - } - } onCancel: { - Task { - await self.cancelWaiter(id: waiterID) - } - } + package init(failures: [ReviewRetainedThreadCleanupFailure] = []) { + self.failures = failures } - private func cancelWaiter(id: UUID) { - waiters.removeValue(forKey: id)?.resume(returning: .finished) + package var succeeded: Bool { + failures.isEmpty } - private func resumeWaitersForTerminal() { - guard bufferedEvents.isEmpty, let terminal else { - return - } - let delivery = delivery(for: terminal) - let waiters = Array(waiters.values) - self.waiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume(returning: delivery) - } - } - - private func delivery(for terminal: Terminal) -> Delivery { - switch terminal { - case .finished: - return .finished - case .cancelled: - return .cancelled - case .failed(let message): - return .failed(message) - } - } - - private static func isTerminal(_ event: CodexReviewBackendModel.Review.Event) -> Bool { - switch event { - case .completed, .failed, .cancelled: - return true - case .started: - return false + package var failureMessage: String? { + guard failures.isEmpty == false else { + return nil } + return failures + .map { "\($0.threadID.rawValue): \($0.message)" } + .joined(separator: "; ") } } -package struct BackendReviewEventMailboxError: LocalizedError, Sendable { - package var message: String - - package init(message: String) { - self.message = message - } - - package var errorDescription: String? { - message +package struct BackendReviewAttempt: Sendable { + package let attempt: ReviewAttempt + package let observeTerminal: @Sendable () async throws -> ReviewBackendObservedTerminal + package let observedTerminalIfKnown: @Sendable () async -> ReviewBackendObservedTerminal? + package let finalizeTerminal: @Sendable (ReviewBackendObservedTerminal) async -> ReviewBackendTerminal + + package init( + attempt: ReviewAttempt, + observeTerminal: @escaping @Sendable () async throws -> ReviewBackendObservedTerminal, + observedTerminalIfKnown: @escaping @Sendable () async -> ReviewBackendObservedTerminal?, + finalizeTerminal: @escaping @Sendable (ReviewBackendObservedTerminal) async -> ReviewBackendTerminal + ) { + self.attempt = attempt + self.observeTerminal = observeTerminal + self.observedTerminalIfKnown = observedTerminalIfKnown + self.finalizeTerminal = finalizeTerminal } } diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 82a60298..5107fffd 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -4,7 +4,6 @@ package enum CodexReviewBackendModel { package enum Settings {} package enum Account {} package enum Auth {} - package enum Login {} package enum Review {} } @@ -158,55 +157,19 @@ package extension CodexReviewBackendModel.Auth { } } -package extension CodexReviewBackendModel.Login { - struct Request: Codable, Equatable, Sendable { - package var preferredAccountID: CodexReviewBackendModel.Account.ID? - package var nativeWebAuthenticationCallbackScheme: String? - - package init( - preferredAccountID: CodexReviewBackendModel.Account.ID? = nil, - nativeWebAuthenticationCallbackScheme: String? = nil - ) { - self.preferredAccountID = preferredAccountID - self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme - } - } -} - -package extension CodexReviewBackendModel.Login { - struct Challenge: Codable, Equatable, Sendable { - package var id: String - package var verificationURL: URL? - package var userCode: String? - package var nativeWebAuthenticationCallbackScheme: String? - - package init( - id: String, - verificationURL: URL? = nil, - userCode: String? = nil, - nativeWebAuthenticationCallbackScheme: String? = nil - ) { - self.id = id - self.verificationURL = verificationURL - self.userCode = userCode - self.nativeWebAuthenticationCallbackScheme = nativeWebAuthenticationCallbackScheme - } - } -} - package extension CodexReviewBackendModel.Review { struct Start: Equatable, Sendable { - package var runID: String + package var runID: ReviewRunID package var sessionID: String package var request: CodexReviewAPI.Start.Request package var model: String? - package init(runID: String, sessionID: String, request: CodexReviewAPI.Start.Request) { + package init(runID: ReviewRunID, sessionID: String, request: CodexReviewAPI.Start.Request) { self.init(runID: runID, sessionID: sessionID, request: request, model: nil) } package init( - runID: String, + runID: ReviewRunID, sessionID: String, request: CodexReviewAPI.Start.Request, model: String? @@ -219,84 +182,164 @@ package extension CodexReviewBackendModel.Review { } } -package extension CodexReviewBackendModel.Review { - struct Run: Codable, Equatable, Sendable { - package var attemptID: String - package var threadID: String - package var turnID: String? - package var reviewThreadID: String? - package var model: String? - - enum CodingKeys: String, CodingKey { - case attemptID - case threadID - case turnID - case reviewThreadID - case model - } - - package init( - attemptID: String = "attempt-1", - threadID: String, - turnID: String? = nil, - reviewThreadID: String? = nil, - model: String? = nil - ) { - self.attemptID = attemptID - self.threadID = threadID - self.turnID = turnID - self.reviewThreadID = reviewThreadID - self.model = model - } - - package init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.attemptID = try container.decodeIfPresent(String.self, forKey: .attemptID) ?? "attempt-1" - self.threadID = try container.decode(String.self, forKey: .threadID) - self.turnID = try container.decodeIfPresent(String.self, forKey: .turnID) - self.reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID) - self.model = try container.decodeIfPresent(String.self, forKey: .model) - } - } -} - package extension CodexReviewBackendModel.Review { struct RestartToken: Equatable, Sendable { package var id: String - package var interruptedRun: CodexReviewBackendModel.Review.Run + package var interruptedAttempt: ReviewAttempt package init( id: String, - interruptedRun: CodexReviewBackendModel.Review.Run + interruptedAttempt: ReviewAttempt ) { self.id = id - self.interruptedRun = interruptedRun + self.interruptedAttempt = interruptedAttempt } } } -package extension CodexReviewBackendModel.Review { - struct Completion: Equatable, Sendable { - package var finalReview: String? +package struct ReviewTurnFailure: Codable, Hashable, Sendable { + package enum Code: Codable, Hashable, Sendable { + case contextWindowExceeded + case sessionBudgetExceeded + case usageLimitExceeded + case serverOverloaded + case cyberPolicy + case httpConnectionFailed(status: UInt16?) + case responseStreamConnectionFailed(status: UInt16?) + case internalServerError + case unauthorized + case badRequest + case threadRollbackFailed + case sandboxError + case responseStreamDisconnected(status: UInt16?) + case responseTooManyFailedAttempts(status: UInt16?) + case activeTurnNotSteerable(kind: String) + case other + case unknown(rawValue: String) + } - package init(finalReview: String?) { - self.finalReview = finalReview?.nilIfEmpty - } + package var message: String + package var code: Code? + package var additionalDetails: String? + + package init( + message: String, + code: Code? = nil, + additionalDetails: String? = nil + ) { + self.message = message + self.code = code + self.additionalDetails = additionalDetails } } -package extension CodexReviewBackendModel.Review { - enum Event: Equatable, Sendable { - case started(turnID: String, reviewThreadID: String?, model: String?) - case completed(CodexReviewBackendModel.Review.Completion) - case failed(String) - case cancelled(String) +package enum ReviewBackendConnectionTermination: Codable, Hashable, Sendable { + case closed + case transport(message: String) + case processExited(status: Int32?) +} + +package struct ReviewBackendOperationFailure: Codable, Hashable, Sendable { + package enum Operation: String, Codable, Hashable, Sendable { + case startReview + case interruptReview + case prepareRestart + case restartReview + } + + package enum LaunchKind: String, Codable, Hashable, Sendable { + case executableNotFound + case scaffold + case spawn + } + + package enum RequestKind: Codable, Hashable, Sendable { + case encode + case write + case transport + case server(code: Int, turnFailure: ReviewTurnFailure?) + case invalidResponse(expectedType: String) + case deadlineExceeded + case overloadRetryExhausted( + lastCode: Int, + lastTurnFailure: ReviewTurnFailure?, + attempts: Int + ) + } + + package enum Reason: Codable, Hashable, Sendable { + case launch(LaunchKind) + case request(requestID: Int, method: String, kind: RequestKind) + case connectionTerminated(ReviewBackendConnectionTermination) + case turnDeadlineExceeded(turnID: ReviewTurnID, duration: Duration) + case malformedNotification(method: String) + case reviewRestartUnavailable + } + + package let operation: Operation + package let reason: Reason + package let message: String + + package init(operation: Operation, reason: Reason, message: String) { + self.operation = operation + self.reason = reason + self.message = message } } -package extension CodexReviewBackendModel.Review.Event { - static func completed(finalReview: String?) -> Self { - .completed(.init(finalReview: finalReview)) +package enum ReviewBackendFailure: Error, Codable, Hashable, Sendable { + case operation(ReviewBackendOperationFailure) + case missingReviewOutput(turnID: ReviewTurnID) + case outputPublication(ReviewOutputPublicationFailure) + case invalidTerminalStatus( + rawStatus: String, + turnID: ReviewTurnID, + turnFailure: ReviewTurnFailure? + ) + case turnFailed(ReviewTurnFailure) + case interruptedByBackend(message: String?) + case connectionTerminated(ReviewBackendConnectionTermination) + case retentionJournal(message: String) + case connectivityObservationEnded + case prepareRestartCancelledUnexpectedly + case restartCancelledUnexpectedly + case protocolViolation(message: String) + + package var message: String { + switch self { + case .operation(let failure): + failure.message + case .missingReviewOutput: + "Review completed without review output." + case .outputPublication(let failure): + failure.message + case .invalidTerminalStatus(let rawStatus, _, _): + "Review ended with invalid terminal status \(rawStatus)." + case .turnFailed(let failure): + failure.message + case .interruptedByBackend(let message): + message?.nilIfEmpty ?? "Review was interrupted by the backend." + case .connectionTerminated(let termination): + switch termination { + case .closed: + "The review backend connection closed." + case .transport(let message): + message + case .processExited(let status): + status.map { "The review backend process exited with status \($0)." } + ?? "The review backend process exited." + } + case .retentionJournal(let message): + message + case .connectivityObservationEnded: + "Network connectivity observation ended unexpectedly." + case .prepareRestartCancelledUnexpectedly: + "Review restart preparation was cancelled unexpectedly." + case .restartCancelledUnexpectedly: + "Review restart was cancelled unexpectedly." + case .protocolViolation(let message): + message + } } } diff --git a/Sources/CodexReviewKit/Model/CodexReviewAccount.swift b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift index 54e17c46..9fd90fb5 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewAccount.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift @@ -3,17 +3,17 @@ import Observation @MainActor @Observable -public final class CodexReviewAccount: Identifiable, Hashable { +package final class CodexReviewAccount: Identifiable, Hashable { @MainActor @Observable - public final class RateLimitWindow: Identifiable, Hashable { - nonisolated public let id: String - nonisolated public let accountKey: String - nonisolated public let windowDurationMinutes: Int - public var usedPercent: Int - public var resetsAt: Date? - - public init( + package final class RateLimitWindow: Identifiable, Hashable { + nonisolated package let id: String + nonisolated package let accountKey: String + nonisolated package let windowDurationMinutes: Int + package var usedPercent: Int + package var resetsAt: Date? + + package init( accountKey: String = "__standalone__", windowDurationMinutes: Int, usedPercent: Int, @@ -35,47 +35,47 @@ public final class CodexReviewAccount: Identifiable, Hashable { self.resetsAt = resetsAt } - public static nonisolated func == (lhs: RateLimitWindow, rhs: RateLimitWindow) -> Bool { + package static nonisolated func == (lhs: RateLimitWindow, rhs: RateLimitWindow) -> Bool { lhs.id == rhs.id } - public nonisolated func hash(into hasher: inout Hasher) { + package nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id) } } - public nonisolated static func normalizedEmail(_ email: String) -> String { + package nonisolated static func normalizedEmail(_ email: String) -> String { email .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() } - nonisolated public let id: String - public package(set) var email: String - public package(set) var maskedEmail: String + nonisolated package let id: String + package var email: String + package var maskedEmail: String package private(set) var kind: CodexReviewBackendModel.Account.Kind - public var planType: String? + package var planType: String? package private(set) var capabilities: CodexReviewBackendModel.Account.Capabilities - public package(set) var rateLimits: [RateLimitWindow] = [] - public package(set) var isSwitching = false - public package(set) var lastRateLimitFetchAt: Date? - public package(set) var lastRateLimitError: String? + package var rateLimits: [RateLimitWindow] = [] + package var isSwitching = false + package var lastRateLimitFetchAt: Date? + package var lastRateLimitError: String? - public var requiresReauthentication: Bool { + package var requiresReauthentication: Bool { lastRateLimitError.map(Self.requiresReauthentication(errorMessage:)) ?? false } - public var rateLimitStatusMessage: String? { + package var rateLimitStatusMessage: String? { if requiresReauthentication { return "Sign in again" } return lastRateLimitError } - nonisolated public var accountKey: String { + nonisolated package var accountKey: String { id } - public convenience init( + package convenience init( accountKey: String? = nil, email: String, planType: String? = nil @@ -343,11 +343,11 @@ private func maskedReviewAccountEmailSegment(_ segment: String) -> String { } extension CodexReviewAccount { - public static nonisolated func == (lhs: CodexReviewAccount, rhs: CodexReviewAccount) -> Bool { + package static nonisolated func == (lhs: CodexReviewAccount, rhs: CodexReviewAccount) -> Bool { lhs.id == rhs.id } - public nonisolated func hash(into hasher: inout Hasher) { + package nonisolated func hash(into hasher: inout Hasher) { hasher.combine(id) } } diff --git a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift index 506f73fd..2ec2c0df 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift @@ -1,9 +1,43 @@ import Foundation import Observation +package enum CodexReviewAuthenticationFailure: Error, Equatable, Sendable { + case alreadyInProgress + case accountMutationBlockedByAuthentication + case runtime(message: String) + case urlOpen(URL) + case login(message: String?) + case nonExportableCredentialStore + case persistenceInconsistent(message: String) + case accountCommit(message: String) + case protocolViolation(message: String) +} + +extension CodexReviewAuthenticationFailure: LocalizedError { + package var errorDescription: String? { + switch self { + case .alreadyInProgress: + "Authentication is already in progress." + case .accountMutationBlockedByAuthentication: + "The account cannot be changed while authentication is in progress." + case .runtime(let message), + .persistenceInconsistent(let message), + .accountCommit(let message), + .protocolViolation(let message): + message + case .urlOpen(let url): + "Failed to open the authentication URL: \(url.absoluteString)" + case .login(let message): + message ?? "Authentication failed." + case .nonExportableCredentialStore: + "The authenticated credentials cannot be imported into the account registry." + } + } +} + @MainActor @Observable -public final class CodexReviewAuthModel { +package final class CodexReviewAuthModel { package enum PendingAccountAction: Equatable, Sendable { case switchAccount(accountKey: String) case signOutActiveAccount @@ -52,13 +86,13 @@ public final class CodexReviewAuthModel { package let message: String } - public struct Progress: Sendable, Equatable { - public var title: String - public var detail: String - public var browserURL: String? - public var userCode: String? + package struct Progress: Sendable, Equatable { + package var title: String + package var detail: String + package var browserURL: String? + package var userCode: String? - public init( + package init( title: String, detail: String, browserURL: String? = nil, @@ -71,54 +105,52 @@ public final class CodexReviewAuthModel { } } - public enum Phase: Sendable, Equatable { + package enum Phase: Sendable, Equatable { case signedOut case signingIn(Progress) - case failed(message: String) + case failed(CodexReviewAuthenticationFailure) } - public package(set) var phase: Phase = .signedOut - public package(set) var persistedAccounts: [CodexReviewAccount] = [] - public package(set) var persistedActiveAccountKey: String? + package var phase: Phase = .signedOut + package var persistedAccounts: [CodexReviewAccount] = [] + package var persistedActiveAccountKey: String? package private(set) var detachedAccount: CodexReviewAccount? - public private(set) var selectedAccount: CodexReviewAccount? + package private(set) var selectedAccount: CodexReviewAccount? - public package(set) var authenticationFailureCount = 0 - public package(set) var warningMessage: String? package private(set) var pendingAccountAction: PendingAccountAction? package private(set) var accountActionAlert: AccountActionAlert? - public var progress: Progress? { + package var progress: Progress? { guard case .signingIn(let progress) = phase else { return nil } return progress } - public var isAuthenticating: Bool { + package var isAuthenticating: Bool { progress != nil } - public var isAuthenticated: Bool { + package var isAuthenticated: Bool { selectedAccount != nil } - public var accounts: [CodexReviewAccount] { + package var accounts: [CodexReviewAccount] { guard let detachedAccount else { return persistedAccounts } return persistedAccounts + [detachedAccount] } - public var hasAccounts: Bool { + package var hasAccounts: Bool { accounts.isEmpty == false } - public var errorMessage: String? { - guard case .failed(let message) = phase else { + package var errorMessage: String? { + guard case .failed(let failure) = phase else { return nil } - return message + return failure.localizedDescription } package static func makePreview() -> CodexReviewAuthModel { @@ -195,16 +227,6 @@ public final class CodexReviewAuthModel { self.phase = phase } - package func recordAuthenticationFailure(message: String) { - authenticationFailureCount += 1 - warningMessage = nil - phase = .failed(message: message) - } - - package func updateWarning(message: String?) { - warningMessage = message?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - } - package func selectPersistedAccount(_ persistedAccountID: CodexReviewAccount.ID?) { guard let persistedAccountID else { selectedAccount = nil diff --git a/Sources/CodexReviewKit/Model/CodexReviewServerState.swift b/Sources/CodexReviewKit/Model/CodexReviewServerState.swift index 50125210..3c2fd310 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewServerState.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewServerState.swift @@ -1,10 +1,10 @@ -public enum CodexReviewServerState: Sendable, Equatable { +package enum CodexReviewServerState: Sendable, Equatable { case stopped case starting case running case failed(String) - public var isRestartAvailable: Bool { + package var isRestartAvailable: Bool { switch self { case .stopped, .starting, .failed: true @@ -13,7 +13,7 @@ public enum CodexReviewServerState: Sendable, Equatable { } } - public var displayText: String { + package var displayText: String { switch self { case .stopped: "Stopped" @@ -26,7 +26,7 @@ public enum CodexReviewServerState: Sendable, Equatable { } } - public var failureMessage: String? { + package var failureMessage: String? { guard case .failed(let message) = self else { return nil } diff --git a/Sources/CodexReviewKit/Model/ParsedReviewResult.swift b/Sources/CodexReviewKit/Model/ParsedReviewResult.swift index 29631fe9..fd4efb0a 100644 --- a/Sources/CodexReviewKit/Model/ParsedReviewResult.swift +++ b/Sources/CodexReviewKit/Model/ParsedReviewResult.swift @@ -1,38 +1,38 @@ import Foundation -public struct ParsedReviewResult: Codable, Sendable, Hashable { - public enum State: String, Codable, Sendable, Hashable { +package struct ParsedReviewResult: Codable, Sendable, Hashable { + package enum State: String, Codable, Sendable, Hashable { case hasFindings case noFindings case unknown } - public enum Source: String, Codable, Sendable, Hashable { + package enum Source: String, Codable, Sendable, Hashable { case parsedFinalReviewText case unrecognizedFindingBlock case notAvailable } - public struct Finding: Codable, Sendable, Hashable { - public struct Location: Codable, Sendable, Hashable { - public var path: String - public var startLine: Int - public var endLine: Int + package struct Finding: Codable, Sendable, Hashable { + package struct Location: Codable, Sendable, Hashable { + package var path: String + package var startLine: Int + package var endLine: Int - public init(path: String, startLine: Int, endLine: Int) { + package init(path: String, startLine: Int, endLine: Int) { self.path = path self.startLine = startLine self.endLine = endLine } } - public var title: String - public var body: String - public var priority: Int? - public var location: Location? - public var rawText: String + package var title: String + package var body: String + package var priority: Int? + package var location: Location? + package var rawText: String - public init( + package init( title: String, body: String, priority: Int? = nil, @@ -47,15 +47,15 @@ public struct ParsedReviewResult: Codable, Sendable, Hashable { } } - public static let currentParserVersion = 1 + package static let currentParserVersion = 1 - public var state: State - public var findingCount: Int? - public var findings: [Finding] - public var source: Source - public var parserVersion: Int + package var state: State + package var findingCount: Int? + package var findings: [Finding] + package var source: Source + package var parserVersion: Int - public init( + package init( state: State, findingCount: Int?, findings: [Finding], @@ -69,7 +69,7 @@ public struct ParsedReviewResult: Codable, Sendable, Hashable { self.parserVersion = parserVersion } - public static func notAvailable() -> ParsedReviewResult { + package static func notAvailable() -> ParsedReviewResult { ParsedReviewResult( state: .unknown, findingCount: nil, @@ -78,7 +78,7 @@ public struct ParsedReviewResult: Codable, Sendable, Hashable { ) } - public static func parse(finalReviewText text: String?) -> ParsedReviewResult { + package static func parse(finalReviewText text: String?) -> ParsedReviewResult { guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false else { diff --git a/Sources/CodexReviewKit/Model/ReviewIdentity.swift b/Sources/CodexReviewKit/Model/ReviewIdentity.swift new file mode 100644 index 00000000..daff1d2d --- /dev/null +++ b/Sources/CodexReviewKit/Model/ReviewIdentity.swift @@ -0,0 +1,134 @@ +import Foundation + +package enum ReviewIdentityValidationError: Error, Equatable, Sendable { + case empty(field: String) +} + +package struct ReviewRunID: Codable, Hashable, Sendable { + package let rawValue: String + + package init(validating rawValue: String) throws { + self.rawValue = try validatedReviewIdentity(rawValue, field: "runID") + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +package struct ReviewAttemptID: Codable, Hashable, Sendable { + package let rawValue: String + + package init(validating rawValue: String) throws { + self.rawValue = try validatedReviewIdentity(rawValue, field: "attemptID") + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +package struct ReviewThreadID: Codable, Hashable, Sendable { + package let rawValue: String + + package init(validating rawValue: String) throws { + self.rawValue = try validatedReviewIdentity(rawValue, field: "threadID") + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +package struct ReviewTurnID: Codable, Hashable, Sendable { + package let rawValue: String + + package init(validating rawValue: String) throws { + self.rawValue = try validatedReviewIdentity(rawValue, field: "turnID") + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +package struct ReviewThreadIdentity: Codable, Hashable, Sendable { + package let sourceThreadID: ReviewThreadID + package let activeTurnThreadID: ReviewThreadID + + package init( + sourceThreadID: ReviewThreadID, + activeTurnThreadID: ReviewThreadID + ) { + self.sourceThreadID = sourceThreadID + self.activeTurnThreadID = activeTurnThreadID + } +} + +package struct ReviewAttempt: Codable, Hashable, Sendable { + package let attemptID: ReviewAttemptID + package let threadIdentity: ReviewThreadIdentity + package let turnID: ReviewTurnID + package let model: String? + + package init( + attemptID: ReviewAttemptID, + threadIdentity: ReviewThreadIdentity, + turnID: ReviewTurnID, + model: String? + ) { + self.attemptID = attemptID + self.threadIdentity = threadIdentity + self.turnID = turnID + self.model = model + } + + package init( + validatingAttemptID attemptID: String, + sourceThreadID: String, + activeTurnThreadID: String, + turnID: String, + model: String? = nil + ) throws { + self.init( + attemptID: try ReviewAttemptID(validating: attemptID), + threadIdentity: .init( + sourceThreadID: try ReviewThreadID(validating: sourceThreadID), + activeTurnThreadID: try ReviewThreadID(validating: activeTurnThreadID) + ), + turnID: try ReviewTurnID(validating: turnID), + model: model + ) + } +} + +private func validatedReviewIdentity(_ rawValue: String, field: String) throws -> String { + guard rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw ReviewIdentityValidationError.empty(field: field) + } + return rawValue +} diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 3897a280..d5351419 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -1,71 +1,108 @@ import Foundation -package struct ReviewRunCore: Codable, Sendable, Hashable { - package struct Run: Codable, Sendable, Hashable { - package var attemptID: String? - package var reviewThreadID: String? - package var threadID: String? - package var turnID: String? - package var model: String? +package enum ReviewRunCore: Codable, Sendable, Hashable { + case queued + case startFailed( + endedAt: Date, + failure: ReviewBackendFailure + ) + case cancelledBeforeStart( + endedAt: Date, + cancellation: ReviewCancellation + ) + case running( + attempt: ReviewAttempt, + startedAt: Date + ) + case succeeded( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date + ) + case failed( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date, + failure: ReviewBackendFailure + ) + case cancelled( + attempt: ReviewAttempt, + startedAt: Date, + endedAt: Date, + cancellation: ReviewCancellation + ) - package init( - attemptID: String? = nil, - reviewThreadID: String? = nil, - threadID: String? = nil, - turnID: String? = nil, - model: String? = nil - ) { - self.attemptID = attemptID - self.reviewThreadID = reviewThreadID - self.threadID = threadID - self.turnID = turnID - self.model = model + package var status: ReviewRunState { + switch self { + case .queued: + .queued + case .startFailed, .failed: + .failed + case .cancelledBeforeStart, .cancelled: + .cancelled + case .running: + .running + case .succeeded: + .succeeded } } - package struct Lifecycle: Codable, Sendable, Hashable { - package var status: ReviewRunState - package var exitCode: Int? - package var startedAt: Date? - package var endedAt: Date? - package var cancellation: ReviewCancellation? - package var errorMessage: String? + package var attempt: ReviewAttempt? { + switch self { + case .queued, .startFailed, .cancelledBeforeStart: + nil + case .running(let attempt, _), + .succeeded(let attempt, _, _), + .failed(let attempt, _, _, _), + .cancelled(let attempt, _, _, _): + attempt + } + } + + package var startedAt: Date? { + switch self { + case .queued, .startFailed, .cancelledBeforeStart: + nil + case .running(_, let startedAt), + .succeeded(_, let startedAt, _), + .failed(_, let startedAt, _, _), + .cancelled(_, let startedAt, _, _): + startedAt + } + } - package init( - status: ReviewRunState, - exitCode: Int? = nil, - startedAt: Date? = nil, - endedAt: Date? = nil, - cancellation: ReviewCancellation? = nil, - errorMessage: String? = nil - ) { - self.status = status - self.exitCode = exitCode - self.startedAt = startedAt - self.endedAt = endedAt - self.cancellation = cancellation - self.errorMessage = errorMessage + package var endedAt: Date? { + switch self { + case .queued, .running: + nil + case .startFailed(let endedAt, _), + .cancelledBeforeStart(let endedAt, _), + .succeeded(_, _, let endedAt), + .failed(_, _, let endedAt, _), + .cancelled(_, _, let endedAt, _): + endedAt } } - package var run: Run - package var lifecycle: Lifecycle - package var lifecycleMessage: String - package var finalReview: String? + package var failure: ReviewBackendFailure? { + switch self { + case .startFailed(_, let failure), .failed(_, _, _, let failure): + failure + case .queued, .cancelledBeforeStart, .running, .succeeded, .cancelled: + nil + } + } - package init( - run: Run = .init(), - lifecycle: Lifecycle, - lifecycleMessage: String, - finalReview: String? = nil - ) { - self.run = run - self.lifecycle = lifecycle - self.lifecycleMessage = lifecycleMessage - self.finalReview = finalReview?.nilIfEmpty + package var cancellation: ReviewCancellation? { + switch self { + case .cancelledBeforeStart(_, let cancellation), .cancelled(_, _, _, let cancellation): + cancellation + case .queued, .startFailed, .running, .succeeded, .failed: + nil + } } package var isTerminal: Bool { - lifecycle.status.isTerminal + status.isTerminal } } diff --git a/Sources/CodexReviewKit/Model/ReviewRunPresentation.swift b/Sources/CodexReviewKit/Model/ReviewRunPresentation.swift new file mode 100644 index 00000000..cb69f609 --- /dev/null +++ b/Sources/CodexReviewKit/Model/ReviewRunPresentation.swift @@ -0,0 +1,94 @@ +import Foundation + +package enum ReviewExecutionPhase: Equatable, Sendable { + case starting + case running(attemptGeneration: UInt64) + case preparingRestart + case waitingForNetwork(since: Date) + case restarting + case cancelling(ReviewCancellation) +} + +package enum ReviewLifecyclePresentation: Codable, Hashable, Sendable { + case queued + case starting + case running + case waitingForNetwork(since: Date) + case preparingRestart + case restarting + case cancelling(ReviewCancellation) + case succeeded + case failed(ReviewBackendFailure) + case cancelled(ReviewCancellation) + + package var message: String { + switch self { + case .queued: + "Queued." + case .starting: + "Starting review." + case .running: + "Review started." + case .waitingForNetwork: + "Network unavailable; waiting to reconnect." + case .preparingRestart: + "Preparing review restart." + case .restarting: + "Network restored; restarting review." + case .cancelling(let cancellation), .cancelled(let cancellation): + cancellation.message + case .succeeded: + "Review completed." + case .failed(let failure): + failure.message + } + } +} + +package struct ReviewRunPresentation: Codable, Hashable, Sendable { + package let status: ReviewRunState + package let lifecycle: ReviewLifecyclePresentation + package let isCancellable: Bool + + package init(core: ReviewRunCore, executionPhase: ReviewExecutionPhase?) { + status = core.status + switch (core, executionPhase) { + case (.queued, .some(.starting)): + lifecycle = .starting + isCancellable = true + case (.queued, .some(.cancelling(let cancellation))): + lifecycle = .cancelling(cancellation) + isCancellable = false + case (.queued, nil): + lifecycle = .queued + isCancellable = true + case (.running, .some(.running)): + lifecycle = .running + isCancellable = true + case (.running, .some(.preparingRestart)): + lifecycle = .preparingRestart + isCancellable = true + case (.running, .some(.waitingForNetwork(let since))): + lifecycle = .waitingForNetwork(since: since) + isCancellable = true + case (.running, .some(.restarting)): + lifecycle = .restarting + isCancellable = true + case (.running, .some(.cancelling(let cancellation))): + lifecycle = .cancelling(cancellation) + isCancellable = false + case (.succeeded, nil): + lifecycle = .succeeded + isCancellable = false + case (.startFailed(_, let failure), nil), (.failed(_, _, _, let failure), nil): + lifecycle = .failed(failure) + isCancellable = false + case (.cancelledBeforeStart(_, let cancellation), nil), + (.cancelled(_, _, _, let cancellation), nil): + lifecycle = .cancelled(cancellation) + isCancellable = false + default: + preconditionFailure("Invalid review core and execution phase product.") + } + } +} diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift index acb522de..e17241f9 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecord.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecord.swift @@ -4,26 +4,30 @@ import Observation @MainActor @Observable package final class ReviewRunRecord: Identifiable, Hashable { - package nonisolated let id: String + package nonisolated let id: ReviewRunID package let sessionID: String package let cwd: String package var sortOrder: Double package var targetSummary: String package var core: ReviewRunCore + package var executionPhase: ReviewExecutionPhase? package var cancellationRequested: Bool + package var pendingCancellation: ReviewCancellation? package var isTerminal: Bool { core.isTerminal } package init( - id: String, + id: ReviewRunID, sessionID: String, cwd: String, sortOrder: Double = 0, targetSummary: String, - core: ReviewRunCore, - cancellationRequested: Bool = false + core: ReviewRunCore = .queued, + executionPhase: ReviewExecutionPhase? = .starting, + cancellationRequested: Bool = false, + pendingCancellation: ReviewCancellation? = nil ) { self.id = id self.sessionID = sessionID @@ -31,7 +35,13 @@ package final class ReviewRunRecord: Identifiable, Hashable { self.sortOrder = sortOrder self.targetSummary = targetSummary self.core = core + self.executionPhase = executionPhase self.cancellationRequested = cancellationRequested + self.pendingCancellation = pendingCancellation + } + + package var presentation: ReviewRunPresentation { + ReviewRunPresentation(core: core, executionPhase: executionPhase) } package nonisolated static func == (lhs: ReviewRunRecord, rhs: ReviewRunRecord) -> Bool { diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index 49c8f5ea..845081d6 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -7,7 +7,9 @@ extension ReviewRunRecord { cwd: String = "/tmp/repo", targetSummary: String, model: String? = "gpt-5", + attemptID: String? = nil, threadID: String? = nil, + reviewThreadID: String? = nil, turnID: String? = nil, status: ReviewRunState, cancellationRequested: Bool = false, @@ -16,34 +18,92 @@ extension ReviewRunRecord { endedAt: Date? = nil, summary: String, errorMessage: String? = nil, + failure: ReviewBackendFailure? = nil, exitCode: Int? = nil ) -> ReviewRunRecord { - ReviewRunRecord( - id: id, + let attempt = makeTestingAttempt( + attemptID: attemptID, + threadID: threadID, + reviewThreadID: reviewThreadID, + turnID: turnID, + model: model + ) + let core: ReviewRunCore + switch status { + case .queued: + precondition(attempt == nil, "A queued test review cannot have an attempt.") + core = .queued + case .running: + guard let attempt, let startedAt else { + preconditionFailure("A running test review requires an attempt and startedAt.") + } + core = .running(attempt: attempt, startedAt: startedAt) + case .succeeded: + guard let attempt, let startedAt, let endedAt else { + preconditionFailure("A succeeded test review requires an attempt, startedAt, and endedAt.") + } + core = .succeeded(attempt: attempt, startedAt: startedAt, endedAt: endedAt) + case .failed: + let resolvedFailure = failure ?? .protocolViolation(message: errorMessage ?? summary) + guard let endedAt else { + preconditionFailure("A failed test review requires endedAt.") + } + if let attempt { + guard let startedAt else { + preconditionFailure("A failed test review attempt requires startedAt.") + } + core = .failed( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + failure: resolvedFailure + ) + } else { + core = .startFailed(endedAt: endedAt, failure: resolvedFailure) + } + case .cancelled: + let resolvedCancellation = cancellation ?? .system(message: summary) + guard let endedAt else { + preconditionFailure("A cancelled test review requires endedAt.") + } + if let attempt { + guard let startedAt else { + preconditionFailure("A cancelled test review attempt requires startedAt.") + } + core = .cancelled( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + cancellation: resolvedCancellation + ) + } else { + core = .cancelledBeforeStart( + endedAt: endedAt, + cancellation: resolvedCancellation + ) + } + } + + return ReviewRunRecord( + id: makeTestingRunID(id), sessionID: sessionID, cwd: cwd, targetSummary: targetSummary, - core: ReviewRunCore( - run: .init( - reviewThreadID: threadID, - threadID: threadID, - turnID: turnID, - model: model - ), - lifecycle: .init( - status: status, - exitCode: exitCode, - startedAt: startedAt, - endedAt: endedAt, - cancellation: cancellation, - errorMessage: errorMessage - ), - lifecycleMessage: summary - ), - cancellationRequested: cancellationRequested + core: core, + executionPhase: status == .queued ? .starting : (status == .running ? .running(attemptGeneration: 0) : nil), + cancellationRequested: cancellationRequested, + pendingCancellation: cancellationRequested ? cancellation : nil ) } + private static func makeTestingRunID(_ rawValue: String) -> ReviewRunID { + do { + return try ReviewRunID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review run ID fixture: \(error)") + } + } + package func updateStateForTesting( targetSummary: String? = nil, status: ReviewRunState? = nil, @@ -55,15 +115,95 @@ extension ReviewRunRecord { self.targetSummary = targetSummary } if let status { - core.lifecycle.status = status + core = testingState( + status: status, + endedAt: endedAt, + clearEndedAt: clearEndedAt + ) } - if let endedAt { - core.lifecycle.endedAt = endedAt - } else if clearEndedAt { - core.lifecycle.endedAt = nil - } - if let summary { - core.lifecycleMessage = summary + _ = summary + } + + private func testingState( + status: ReviewRunState, + endedAt: Date?, + clearEndedAt: Bool + ) -> ReviewRunCore { + let resolvedEndedAt = clearEndedAt ? nil : (endedAt ?? core.endedAt) + switch status { + case .queued: + return .queued + case .running: + guard let attempt = core.attempt, let startedAt = core.startedAt else { + preconditionFailure("A running test review requires an existing attempt.") + } + return .running(attempt: attempt, startedAt: startedAt) + case .succeeded: + guard let attempt = core.attempt, + let startedAt = core.startedAt, + let resolvedEndedAt + else { + preconditionFailure("A succeeded test review requires an attempt and timestamps.") + } + return .succeeded(attempt: attempt, startedAt: startedAt, endedAt: resolvedEndedAt) + case .failed: + let failure = core.failure ?? .protocolViolation(message: "Testing review failed.") + guard let resolvedEndedAt else { + preconditionFailure("A failed test review requires endedAt.") + } + if let attempt = core.attempt, let startedAt = core.startedAt { + return .failed( + attempt: attempt, + startedAt: startedAt, + endedAt: resolvedEndedAt, + failure: failure + ) + } + return .startFailed(endedAt: resolvedEndedAt, failure: failure) + case .cancelled: + let cancellation = core.cancellation ?? .system(message: "Testing review cancelled.") + guard let resolvedEndedAt else { + preconditionFailure("A cancelled test review requires endedAt.") + } + if let attempt = core.attempt, let startedAt = core.startedAt { + return .cancelled( + attempt: attempt, + startedAt: startedAt, + endedAt: resolvedEndedAt, + cancellation: cancellation + ) + } + return .cancelledBeforeStart( + endedAt: resolvedEndedAt, + cancellation: cancellation + ) } } } + +private func makeTestingAttempt( + attemptID: String?, + threadID: String?, + reviewThreadID: String?, + turnID: String?, + model: String? +) -> ReviewAttempt? { + let suppliedValues = [attemptID, threadID, turnID].compactMap { $0 } + guard suppliedValues.isEmpty == false else { + return nil + } + guard let attemptID, let threadID, let turnID else { + preconditionFailure("A test review attempt requires attemptID, threadID, and turnID together.") + } + do { + return try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: threadID, + activeTurnThreadID: reviewThreadID ?? threadID, + turnID: turnID, + model: model + ) + } catch { + preconditionFailure("Invalid test review attempt: \(error)") + } +} diff --git a/Sources/CodexReviewKit/Model/ReviewTerminal.swift b/Sources/CodexReviewKit/Model/ReviewTerminal.swift new file mode 100644 index 00000000..ef40a8cc --- /dev/null +++ b/Sources/CodexReviewKit/Model/ReviewTerminal.swift @@ -0,0 +1,74 @@ +import Foundation + +package struct NonEmptyReviewOutput: Codable, Hashable, Sendable { + package let rawValue: String + + package init(validating rawValue: String) throws { + guard rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw ReviewIdentityValidationError.empty(field: "finalReview") + } + self.rawValue = rawValue + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + try self.init(validating: container.decode(String.self)) + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +package struct ReviewCompletion: Equatable, Sendable { + package let finalReview: NonEmptyReviewOutput + + package init(finalReview: NonEmptyReviewOutput) { + self.finalReview = finalReview + } +} + +package struct ReviewCompletionCandidate: Equatable, Sendable { + package let turnID: ReviewTurnID + package let expectedOutput: NonEmptyReviewOutput + + package init(turnID: ReviewTurnID, expectedOutput: NonEmptyReviewOutput) { + self.turnID = turnID + self.expectedOutput = expectedOutput + } +} + +package enum ReviewBackendObservedTerminal: Equatable, Sendable { + case completed(ReviewCompletionCandidate) + case interrupted(message: String?) + case failed(ReviewBackendFailure) +} + +package enum ReviewBackendTerminal: Equatable, Sendable { + case completed(ReviewCompletion) + case interrupted(message: String?) + case failed(ReviewBackendFailure) +} + +package enum ReviewOutputPublicationFailure: Error, Codable, Hashable, Sendable { + case refreshFailed(turnID: ReviewTurnID, message: String) + case unavailable(turnID: ReviewTurnID) + case empty(turnID: ReviewTurnID) + case mismatched(turnID: ReviewTurnID) +} + +package extension ReviewOutputPublicationFailure { + var message: String { + switch self { + case .refreshFailed(_, let message): + "Review output refresh failed: \(message)" + case .unavailable: + "Review output projection is unavailable." + case .empty: + "Review output projection is empty." + case .mismatched: + "Review output projection does not match the backend output." + } + } +} diff --git a/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift b/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift index f3f05e91..42779cc8 100644 --- a/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift +++ b/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift @@ -46,18 +46,18 @@ package protocol CodexReviewNetworkMonitoring: Sendable { } package struct CodexReviewNetworkRecoveryPolicy: Sendable { - package var outageDebounce: Duration - package var recoverySettle: Duration - package var sleep: @Sendable (Duration) async throws -> Void + package let outageDebounce: Duration + package let recoverySettle: Duration + package let clock: ReviewWorkerClock package init( outageDebounce: Duration = .seconds(10), recoverySettle: Duration = .seconds(1), - sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + clock: ReviewWorkerClock = .continuous ) { self.outageDebounce = outageDebounce self.recoverySettle = recoverySettle - self.sleep = sleep + self.clock = clock } package static var `default`: Self { @@ -75,7 +75,6 @@ package struct StaticCodexReviewNetworkMonitor: CodexReviewNetworkMonitoring { package func snapshots() -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in continuation.yield(snapshot) - continuation.finish() } } } diff --git a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift index dc3cd9be..61ff41ac 100644 --- a/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift +++ b/Sources/CodexReviewKit/ReviewAPI/ReviewResults.swift @@ -2,21 +2,25 @@ import Foundation package extension CodexReviewAPI.Read { struct Result: Codable, Sendable, Hashable { - package var runID: String + package var runID: ReviewRunID package var core: ReviewRunCore + package var presentation: ReviewRunPresentation package var elapsedSeconds: Int? - package var cancellable: Bool + + package var cancellable: Bool { + presentation.isCancellable + } package init( - runID: String, + runID: ReviewRunID, core: ReviewRunCore, - elapsedSeconds: Int? = nil, - cancellable: Bool + presentation: ReviewRunPresentation, + elapsedSeconds: Int? = nil ) { self.runID = runID self.core = core + self.presentation = presentation self.elapsedSeconds = elapsedSeconds - self.cancellable = cancellable } } } @@ -24,27 +28,31 @@ struct Result: Codable, Sendable, Hashable { package extension CodexReviewAPI.Run { struct ListItem: Codable, Sendable, Hashable { - package var runID: String + package var runID: ReviewRunID package var cwd: String package var targetSummary: String package var core: ReviewRunCore + package var presentation: ReviewRunPresentation package var elapsedSeconds: Int? - package var cancellable: Bool + + package var cancellable: Bool { + presentation.isCancellable + } package init( - runID: String, + runID: ReviewRunID, cwd: String, targetSummary: String, core: ReviewRunCore, - elapsedSeconds: Int?, - cancellable: Bool + presentation: ReviewRunPresentation, + elapsedSeconds: Int? ) { self.runID = runID self.cwd = cwd self.targetSummary = targetSummary self.core = core + self.presentation = presentation self.elapsedSeconds = elapsedSeconds - self.cancellable = cancellable } } } @@ -63,12 +71,12 @@ struct Result: Codable, Sendable, Hashable { package extension CodexReviewAPI.Run { struct Selector: Sendable, Hashable { - package var runID: String? + package var runID: ReviewRunID? package var cwd: String? package var statuses: [ReviewRunState]? package init( - runID: String? = nil, + runID: ReviewRunID? = nil, cwd: String? = nil, statuses: [ReviewRunState]? = nil ) { @@ -92,7 +100,7 @@ extension CodexReviewAPI.Run.SelectionError: LocalizedError { switch self { case .ambiguous(let reviewRuns): let candidates = reviewRuns - .map { "- \($0.runID) [\($0.core.lifecycle.status.rawValue)] \($0.cwd) \($0.targetSummary)" } + .map { "- \($0.runID.rawValue) [\($0.core.status.rawValue)] \($0.cwd) \($0.targetSummary)" } .joined(separator: "\n") return """ Review run selector matched multiple review runs: @@ -106,18 +114,21 @@ extension CodexReviewAPI.Run.SelectionError: LocalizedError { package extension CodexReviewAPI.Cancel { struct Outcome: Codable, Sendable, Hashable { - package var runID: String + package var runID: ReviewRunID package var cancelled: Bool package var core: ReviewRunCore + package var presentation: ReviewRunPresentation package init( - runID: String, + runID: ReviewRunID, cancelled: Bool, - core: ReviewRunCore + core: ReviewRunCore, + presentation: ReviewRunPresentation ) { self.runID = runID self.cancelled = cancelled self.core = core + self.presentation = presentation } } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index b2c5d158..d348fcf7 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -4,10 +4,10 @@ import Observation @MainActor @Observable public final class CodexReviewStore { - public package(set) var serverState: CodexReviewServerState = .stopped - public let auth: CodexReviewAuthModel + package var serverState: CodexReviewServerState = .stopped + package let auth: CodexReviewAuthModel package let settings: SettingsStore - public package(set) var serverURL: URL? + package var serverURL: URL? package var reviewRuns: Set = [] package var shouldAutoStartEmbeddedServer: Bool { backend.seed.shouldAutoStartEmbeddedServer @@ -20,7 +20,8 @@ public final class CodexReviewStore { @ObservationIgnored package let networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy @ObservationIgnored package let clock: CodexReviewClock @ObservationIgnored package let idGenerator: CodexReviewIDGenerator - @ObservationIgnored let runtimeState = CodexReviewStoreRuntimeState() + @ObservationIgnored package let reviewThreadRetentionRegistry: ReviewThreadRetentionRegistry + @ObservationIgnored let runtimeState = ReviewStoreRuntime() @ObservationIgnored package var closedSessions: Set = [] @ObservationIgnored package var accountRateLimitAutoRefreshDriver: CodexReviewStoreRateLimitAutoRefreshDriver? @@ -31,7 +32,8 @@ public final class CodexReviewStore { clock: CodexReviewClock = .init(), idGenerator: CodexReviewIDGenerator = .init(), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), - networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default + networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, + reviewThreadRetentionJournal: (any ReviewThreadRetentionJournaling)? = nil ) { self.backend = backend self.networkMonitor = networkMonitor @@ -39,6 +41,15 @@ public final class CodexReviewStore { self.diagnosticsURL = diagnosticsURL self.clock = clock self.idGenerator = idGenerator + let retentionJournal: any ReviewThreadRetentionJournaling + if let reviewThreadRetentionJournal { + retentionJournal = reviewThreadRetentionJournal + } else if let journalURL = backend.reviewThreadRetentionJournalURL { + retentionJournal = FileReviewThreadRetentionJournal(fileURL: journalURL) + } else { + retentionJournal = InMemoryReviewThreadRetentionJournal() + } + self.reviewThreadRetentionRegistry = ReviewThreadRetentionRegistry(journal: retentionJournal) self.auth = CodexReviewAuthModel() self.settings = SettingsStore(snapshot: backend.seed.initialSettingsSnapshot) self.settingsService = settingsService ?? CodexReviewSettingsService( @@ -64,19 +75,23 @@ public final class CodexReviewStore { isolated deinit { accountRateLimitAutoRefreshDriver?.cancel() - runtimeState.cancelAllWorkers() + runtimeState.signalCancellation() } - public static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore { + package static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore { makePreviewStore(seed: .init(), diagnosticsURL: diagnosticsURL) } package static func makePreviewStore( seed: CodexReviewStoreSeed, + runtimeLifetime: (any CodexReviewPreviewRuntimeLifetime)? = nil, diagnosticsURL: URL? = nil ) -> CodexReviewStore { CodexReviewStore( - backend: PreviewCodexReviewStoreBackend(seed: seed), + backend: PreviewCodexReviewStoreBackend( + seed: seed, + runtimeLifetime: runtimeLifetime + ), diagnosticsURL: diagnosticsURL, networkMonitor: StaticCodexReviewNetworkMonitor() ) @@ -88,7 +103,8 @@ public final class CodexReviewStore { clock: CodexReviewClock = .init(), idGenerator: CodexReviewIDGenerator = .init(), networkMonitor: any CodexReviewNetworkMonitoring = StaticCodexReviewNetworkMonitor(), - networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default + networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, + reviewThreadRetentionJournal: (any ReviewThreadRetentionJournaling)? = nil ) -> CodexReviewStore { CodexReviewStore( backend: backend, @@ -96,11 +112,22 @@ public final class CodexReviewStore { clock: clock, idGenerator: idGenerator, networkMonitor: networkMonitor, - networkRecoveryPolicy: networkRecoveryPolicy + networkRecoveryPolicy: networkRecoveryPolicy, + reviewThreadRetentionJournal: reviewThreadRetentionJournal ) } public func start(forceRestartIfNeeded: Bool = false) async { + await start( + forceRestartIfNeeded: forceRestartIfNeeded, + restartAdmission: nil + ) + } + + private func start( + forceRestartIfNeeded: Bool, + restartAdmission: CodexReviewRuntimeRestartAdmission? + ) async { switch serverState { case .stopped, .failed: break @@ -114,52 +141,84 @@ public final class CodexReviewStore { serverState = .starting serverURL = nil writeDiagnosticsIfNeeded() - await backend.start(store: self, forceRestartIfNeeded: forceRestartIfNeeded) + if let restartAdmission { + await backend.resumeRuntimeRestart( + store: self, + admission: restartAdmission + ) + } else { + await backend.start(store: self, forceRestartIfNeeded: forceRestartIfNeeded) + } await settingsService.refreshIfRunning(serverState: serverState) startAccountRateLimitAutoRefresh() } public func stop() async { - let locallyCancelledReviewRunIDs: [String] - if backend.invokesRuntimeStopReviewCleanupDuringStop { - locallyCancelledReviewRunIDs = [] - } else { - locallyCancelledReviewRunIDs = await requestActiveReviewCancellationsForRuntimeStop() + await stop(purpose: .finalStoreShutdownRetiringRuns) + } + + private func stop(purpose: CodexReviewRuntimeStopPurpose) async { + if backend.invokesRuntimeStopReviewCleanupDuringStop == false { + _ = await closeActiveReviewSessions( + reason: .system(message: "Review runtime stopped.") + ) + } + if backend.invokesRuntimeStopReviewCleanupDuringStop == false, + purpose.retiresRuns, + await retireReviewRunsForFinalStoreStop() == false + { + transitionToFailed("Review thread retention recovery is quarantined; the runtime remains open.") + return + } + await backend.stop(store: self, purpose: purpose) + runtimeState.signalCancellation() + for task in runtimeState.allWorkerTasks() + runtimeState.cancellationOperationTasks() { + await task.value + } + if purpose.retiresRuns, + await reviewThreadRetentionRegistry.acceptance().isAccepting == false + { + transitionToFailed("Review thread retention recovery is quarantined; the runtime remains open.") + return } - await backend.stop(store: self) - let remainingLocallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop(cancelWorkers: false) - cancelAndDetachReviewWorkersForRuntimeStop( - runIDs: Array(Set(locallyCancelledReviewRunIDs + remainingLocallyCancelledReviewRunIDs)) - ) transitionToStopped() } - public func restart() async { - await stop() - await start(forceRestartIfNeeded: true) + package func restart() async { + guard let admission = backend.beginRuntimeRestart() else { + return + } + await stop(purpose: .runtimeRestartPreservingRuns) + guard backend.claimRuntimeRestart(admission) else { + return + } + await start( + forceRestartIfNeeded: true, + restartAdmission: admission + ) } - public func waitUntilStopped() async { + package func waitUntilStopped() async { await backend.waitUntilStopped() } - public func refreshAuthentication() async { + package func refreshAuthentication() async { await backend.refreshAuth(auth: auth) } - public func signIn() async { - await backend.signIn(auth: auth) + package func signIn() async throws { + try await backend.signIn(auth: auth) } - public func addAccount() async { - await backend.addAccount(auth: auth) + package func addAccount() async throws { + try await backend.addAccount(auth: auth) } - public func cancelAuthentication() async { + package func cancelAuthentication() async { await backend.cancelAuthentication(auth: auth) } - package func performPrimaryAuthenticationAction() async { + package func performPrimaryAuthenticationAction() async throws { if auth.isAuthenticating { await cancelAuthentication() return @@ -175,10 +234,10 @@ public final class CodexReviewStore { else { return } - await signIn() + try await signIn() } - public func logout() async { + package func logout() async { if auth.isAuthenticating, auth.selectedAccount == nil { await cancelAuthentication() return @@ -187,12 +246,12 @@ public final class CodexReviewStore { try await signOutActiveAccount() } catch { if auth.errorMessage == nil, auth.isAuthenticated { - auth.updatePhase(.failed(message: error.localizedDescription)) + auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } } } - public func signOutActiveAccount() async throws { + package func signOutActiveAccount() async throws { try await backend.signOutActiveAccount(auth: auth) } @@ -253,12 +312,6 @@ public final class CodexReviewStore { } do { try await self.executePendingAccountAction(action) - if let warningMessage = self.auth.warningMessage { - self.auth.presentAccountActionAlert( - title: "Account Updated With Warning", - message: warningMessage - ) - } } catch { self.auth.presentAccountActionAlert( title: action.failureTitle, @@ -363,8 +416,8 @@ public final class CodexReviewStore { } let reviewRuns: [CodexReviewStoreDiagnosticsSnapshot.Run] = orderedReviewRuns.map { runRecord in return CodexReviewStoreDiagnosticsSnapshot.Run( - status: runRecord.core.lifecycle.status.rawValue, - lifecycleMessage: runRecord.core.lifecycleMessage + status: runRecord.core.status.rawValue, + lifecycleMessage: runRecord.presentation.lifecycle.message ) } let snapshot = CodexReviewStoreDiagnosticsSnapshot( @@ -390,15 +443,15 @@ public final class CodexReviewStore { writeDiagnosticsIfNeeded() } - public var hasRunningReviewRuns: Bool { + package var hasRunningReviewRuns: Bool { reviewRuns.contains(where: { $0.isTerminal == false }) } - public var runningReviewRunCount: Int { + package var runningReviewRunCount: Int { reviewRuns.filter { $0.isTerminal == false }.count } - public var canPerformPrimaryAuthenticationAction: Bool { + package var canPerformPrimaryAuthenticationAction: Bool { if auth.isAuthenticating { return true } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index 3358a336..fff0814a 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -27,15 +27,24 @@ package struct CodexReviewStoreSeed { package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { var seed: CodexReviewStoreSeed { get } var isActive: Bool { get } + var acceptsNewReviewOperations: Bool { get } var invokesRuntimeStopReviewCleanupDuringStop: Bool { get } + var reviewThreadRetentionCodexHomePath: String { get } + var reviewThreadRetentionJournalURL: URL? { get } func attachStore(_ store: CodexReviewStore) + func beginRuntimeRestart() -> CodexReviewRuntimeRestartAdmission? + func claimRuntimeRestart(_ admission: CodexReviewRuntimeRestartAdmission) -> Bool + func resumeRuntimeRestart( + store: CodexReviewStore, + admission: CodexReviewRuntimeRestartAdmission + ) async func start(store: CodexReviewStore, forceRestartIfNeeded: Bool) async - func stop(store: CodexReviewStore) async + func stop(store: CodexReviewStore, purpose: CodexReviewRuntimeStopPurpose) async func waitUntilStopped() async func refreshAuth(auth: CodexReviewAuthModel) async - func signIn(auth: CodexReviewAuthModel) async - func addAccount(auth: CodexReviewAuthModel) async + func signIn(auth: CodexReviewAuthModel) async throws + func addAccount(auth: CodexReviewAuthModel) async throws func cancelAuthentication(auth: CodexReviewAuthModel) async func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws @@ -45,25 +54,54 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { func requiresCurrentSessionRecovery(auth: CodexReviewAuthModel, accountKey: String) -> Bool func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt - func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws - func prepareReviewRestart(_ run: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken + func interruptReview(_ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws + func prepareReviewRestart(_ attempt: ReviewAttempt) async throws -> CodexReviewBackendModel.Review.RestartToken func restartPreparedReview( _ token: CodexReviewBackendModel.Review.RestartToken, request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt - func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async + func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] + func cleanupReview(_ attempt: ReviewAttempt) async + func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult +} + +package struct CodexReviewRuntimeRestartAdmission: Hashable, Sendable { + package let id: UUID + + package init(id: UUID = UUID()) { + self.id = id + } +} + +package enum CodexReviewRuntimeStopPurpose: Sendable { + case runtimeRestartPreservingRuns + case accountTransitionPreservingRuns + case loginReconciliationPreservingRuns + case finalStoreShutdownRetiringRuns + + package var retiresRuns: Bool { + if case .finalStoreShutdownRetiringRuns = self { + return true + } + return false + } } package struct CodexReviewRuntimeStopReviewCleanupRequest: Sendable { package var reason: CodexReviewBackendModel.CancellationReason - package var recoveryWaitingRuns: [CodexReviewBackendModel.Review.Run] + package var recoveryWaitingAttempts: [ReviewAttempt] package init( reason: CodexReviewBackendModel.CancellationReason, - recoveryWaitingRuns: [CodexReviewBackendModel.Review.Run] + recoveryWaitingAttempts: [ReviewAttempt] ) { self.reason = reason - self.recoveryWaitingRuns = recoveryWaitingRuns + self.recoveryWaitingAttempts = recoveryWaitingAttempts } } @@ -85,7 +123,36 @@ package struct CodexReviewRuntimeStopReviewCleanupResult: Sendable { } extension CodexReviewStoreBackend { + package func beginRuntimeRestart() -> CodexReviewRuntimeRestartAdmission? { + .init() + } + + package func claimRuntimeRestart(_: CodexReviewRuntimeRestartAdmission) -> Bool { + true + } + + package func resumeRuntimeRestart( + store: CodexReviewStore, + admission _: CodexReviewRuntimeRestartAdmission + ) async { + await start(store: store, forceRestartIfNeeded: true) + } + + package var acceptsNewReviewOperations: Bool { + true + } + package var invokesRuntimeStopReviewCleanupDuringStop: Bool { false } + + package var reviewThreadRetentionCodexHomePath: String { + FileManager.default.temporaryDirectory + .appendingPathComponent("CodexReviewKit-volatile", isDirectory: true) + .path + } + + package var reviewThreadRetentionJournalURL: URL? { + nil + } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index a9c80b04..9397e083 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -1,99 +1,107 @@ import Foundation -private actor RuntimeStopDetachedReviewWorkerDrainRace { - private var result: Bool? - private var continuation: CheckedContinuation? - - func finish(_ value: Bool) { - guard result == nil else { - return - } - result = value - continuation?.resume(returning: value) - continuation = nil - } - - func wait() async -> Bool { - if let result { - return result - } - return await withCheckedContinuation { continuation in - if let result { - continuation.resume(returning: result) - } else { - self.continuation = continuation - } - } - } -} - extension CodexReviewStore { package func completeCancellationLocally( - runID: String, + runID: ReviewRunID, sessionID: String, cancellation: ReviewCancellation = .system() ) throws { guard let runRecord = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } guard runRecord.sessionID == sessionID else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } + + commitCancellationLocally(runRecord, cancellation: cancellation) + } + + package func commitCancellationLocally( + _ runRecord: ReviewRunRecord, + cancellation: ReviewCancellation = .system() + ) { + guard let ownedRunRecord = reviewRun(id: runRecord.id) else { + preconditionFailure("A review cancellation can only be committed for a store-owned run.") + } + precondition( + ownedRunRecord === runRecord, + "A review cancellation can only be committed to the store-owned run instance." + ) guard runRecord.isTerminal == false else { return } let endedAt = clock.now() runRecord.cancellationRequested = false - runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.lifecycle.status = .cancelled - runRecord.core.lifecycleMessage = cancellation.message - runRecord.core.lifecycle.errorMessage = - cancellation.message.nilIfEmpty - ?? runRecord.core.lifecycle.errorMessage - runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.finalReview = nil + runRecord.pendingCancellation = nil + switch runRecord.core { + case .queued: + runRecord.core = .cancelledBeforeStart( + endedAt: endedAt, + cancellation: cancellation + ) + case .running(let attempt, let startedAt): + runRecord.core = .cancelled( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + cancellation: cancellation + ) + case .startFailed, .cancelledBeforeStart, .succeeded, .failed, .cancelled: + return + } + runRecord.executionPhase = nil noteReviewRunMutation() } package func recordCancellationFailure( - runID: String, + runID: ReviewRunID, sessionID: String, - message: String + message _: String ) throws { guard let runRecord = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } guard runRecord.sessionID == sessionID else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } + commitCancellationFailure(runRecord) + } + + package func commitCancellationFailure(_ runRecord: ReviewRunRecord) { + guard let ownedRunRecord = reviewRun(id: runRecord.id) else { + preconditionFailure("A cancellation failure can only be committed for a store-owned run.") + } + precondition( + ownedRunRecord === runRecord, + "A cancellation failure can only be committed to the store-owned run instance." + ) + runRecord.cancellationRequested = false - runRecord.core.lifecycle.cancellation = nil - if let message = message.nilIfEmpty { - if message == "Failed to cancel review." { - runRecord.core.lifecycleMessage = message - } else { - runRecord.core.lifecycleMessage = "Failed to cancel review: \(message)" - } - runRecord.core.lifecycle.errorMessage = message - } else { - runRecord.core.lifecycleMessage = "Failed to cancel review." + runRecord.pendingCancellation = nil + switch runRecord.core { + case .queued: + runRecord.executionPhase = .starting + case .running: + runRecord.executionPhase = .running(attemptGeneration: 0) + case .startFailed, .cancelledBeforeStart, .succeeded, .failed, .cancelled: + runRecord.executionPhase = nil } writeDiagnosticsIfNeeded() } package func recordCancellationFailure( - runID: String, + runID: ReviewRunID, message: String ) throws { guard let runRecord = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } try recordCancellationFailure( runID: runID, @@ -102,7 +110,7 @@ extension CodexReviewStore { ) } - public func cancelAllRunningReviewRuns( + package func cancelAllRunningReviewRuns( reason: String = "Cancellation requested." ) async throws { let cancellation = ReviewCancellation.system( @@ -118,12 +126,7 @@ extension CodexReviewStore { cancellation: cancellation ) } catch { - let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - try? recordCancellationFailure( - runID: runRecord.id, - sessionID: runRecord.sessionID, - message: message.isEmpty ? "Failed to cancel review." : message - ) + commitCancellationFailure(runRecord) if firstError == nil { firstError = error } @@ -134,49 +137,81 @@ extension CodexReviewStore { } } - package func requestActiveReviewCancellationsForRuntimeStop( - reason: ReviewCancellation = .system(message: "Review runtime stopped.") - ) async -> [String] { - let activeReviewRunIDs = - orderedReviewRuns - .filter { $0.isTerminal == false } - .map(\.id) - for runID in activeReviewRunIDs { - _ = try? await cancelReview(runID: runID, cancellation: reason) - } - return activeReviewRunIDs - } - package func cleanupActiveReviewsForRuntimeStop( reason: ReviewCancellation = .system(message: "Review runtime stopped."), - workerDrainTimeout: Duration, cleanupBackendReviews: @escaping @Sendable ( CodexReviewRuntimeStopReviewCleanupRequest ) async -> Bool ) async -> CodexReviewRuntimeStopReviewCleanupResult { + let activeRunIDs = Set( + orderedReviewRuns + .filter { $0.isTerminal == false } + .map(\.id) + ) + markActiveReviewCancellationsPendingForRuntimeStop(reason: reason) let request = runtimeStopReviewCleanupRequest(reason: reason) - let didCompleteBackendCleanup = await cleanupBackendReviews(request) - let locallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop( + let didCompleteInitialBackendCleanup = await cleanupBackendReviews(request) + _ = cancelActiveReviewsLocallyForRuntimeStop( reason: reason, cancelWorkers: false ) - cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop( - timeout: workerDrainTimeout - ) + for runID in activeRunIDs { + runtimeState.cancelActiveWorker(for: runID) + } + for task in runtimeState.ownedTasks(for: activeRunIDs) { + await task.value + } + let didCompleteFinalBackendCleanup = await cleanupBackendReviews(request) + let didDrainReviewWorkers = activeRunIDs.allSatisfy { runtimeState.isDrained($0) } return .init( - didCompleteBackendCleanup: didCompleteBackendCleanup, + didCompleteBackendCleanup: + didCompleteInitialBackendCleanup && didCompleteFinalBackendCleanup, didDrainReviewWorkers: didDrainReviewWorkers ) } + package func runtimeStopReviewAttemptOwners() -> [ReviewRunID: ReviewAttempt] { + Dictionary(uniqueKeysWithValues: orderedReviewRuns.compactMap { runRecord in + guard let attempt = runRecord.core.attempt else { + return nil + } + return (runRecord.id, attempt) + }) + } + + package func retainPreparedRestartAttemptsForRuntimeStop( + _ attemptsByRunID: [ReviewRunID: [ReviewAttempt]] + ) async -> Bool { + var didRetainAll = true + for runID in attemptsByRunID.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + for attempt in attemptsByRunID[runID, default: []] { + do { + try await claimReviewThreadOwnership(attempt, for: runID) + } catch { + didRetainAll = false + } + } + } + return didRetainAll + } + + private func markActiveReviewCancellationsPendingForRuntimeStop( + reason: ReviewCancellation + ) { + for runRecord in orderedReviewRuns where runRecord.isTerminal == false { + runRecord.cancellationRequested = true + runRecord.pendingCancellation = reason + runRecord.executionPhase = .cancelling(reason) + } + } + private func runtimeStopReviewCleanupRequest( reason: ReviewCancellation ) -> CodexReviewRuntimeStopReviewCleanupRequest { return .init( reason: .init(message: reason.message), - recoveryWaitingRuns: runtimeState.recoveryWaitingRuns() + recoveryWaitingAttempts: runtimeState.recoveryWaitingAttempts() ) } @@ -184,7 +219,7 @@ extension CodexReviewStore { package func cancelActiveReviewsLocallyForRuntimeStop( reason: ReviewCancellation = .system(message: "Review runtime stopped."), cancelWorkers: Bool = true - ) -> [String] { + ) -> [ReviewRunID] { let activeReviewRunIDs = orderedReviewRuns .filter { $0.isTerminal == false } @@ -195,11 +230,7 @@ extension CodexReviewStore { for runID in activeReviewRunIDs { if let runRecord = reviewRun(id: runID), runRecord.isTerminal == false { - try? completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: reason - ) + commitCancellationLocally(runRecord, cancellation: reason) } if cancelWorkers { runtimeState.cancelActiveWorker(for: runID) @@ -208,56 +239,6 @@ extension CodexReviewStore { return activeReviewRunIDs } - package func cancelAndDetachReviewWorkersForRuntimeStop(runIDs: [String]) { - for runID in runIDs { - runtimeState.cancelAndDetachActiveWorkerForRuntimeStop(runID: runID) - runtimeState.clearRuntimeStopState(for: runID) - } - } - - package func drainRuntimeStopDetachedReviewWorkers(timeout: Duration) async -> Bool { - let tasks = runtimeState.detachedWorkerTasks() - return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) - } - - package func drainReviewWorkersForRuntimeStop(timeout: Duration) async -> Bool { - let tasks = runtimeState.allWorkerTasks() - return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) - } - - private func drainReviewWorkerTasksForRuntimeStop( - _ tasks: [Task], - timeout: Duration - ) async -> Bool { - guard tasks.isEmpty == false else { - return true - } - - let race = RuntimeStopDetachedReviewWorkerDrainRace() - let drainTask = Task { - for task in tasks { - await task.value - } - await race.finish(true) - } - let timeoutTask = Task { - do { - try await Task.sleep(for: timeout) - } catch { - return - } - await race.finish(false) - } - - let didDrain = await race.wait() - if didDrain { - timeoutTask.cancel() - } else { - drainTask.cancel() - } - return didDrain - } - package func terminateAllRunningReviewRunsLocally( reason: String = "Cancellation requested.", failureMessage: String @@ -265,18 +246,27 @@ extension CodexReviewStore { let resolvedError = failureMessage.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty for runRecord in orderedReviewRuns where runRecord.isTerminal == false { runRecord.cancellationRequested = false - runRecord.core.lifecycle.cancellation = nil - runRecord.core.lifecycle.status = .failed - if let resolvedError { - runRecord.core.lifecycleMessage = "Failed to cancel review: \(resolvedError)" - } else { - runRecord.core.lifecycleMessage = "Failed to cancel review." + runRecord.pendingCancellation = nil + let failure = ReviewBackendFailure.connectionTerminated( + .transport( + message: resolvedError ?? reason.nilIfEmpty ?? "Failed to cancel review." + ) + ) + let endedAt = clock.now() + switch runRecord.core { + case .queued: + runRecord.core = .startFailed(endedAt: endedAt, failure: failure) + case .running(let attempt, let startedAt): + runRecord.core = .failed( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + failure: failure + ) + case .startFailed, .cancelledBeforeStart, .succeeded, .failed, .cancelled: + break } - runRecord.core.lifecycle.errorMessage = - resolvedError - ?? reason.nilIfEmpty - ?? runRecord.core.lifecycle.errorMessage - runRecord.core.lifecycle.endedAt = clock.now() + runRecord.executionPhase = nil } noteReviewRunMutation() } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index dd6d55c6..3727cf53 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift @@ -1,16 +1,5 @@ import Foundation -public enum CodexReviewStoreTestEnvironment { - public static let reviewModeKey = "REVIEW_MONITOR_REVIEW_MODE" - public static let portKey = "REVIEW_MONITOR_TEST_PORT" - public static let codexCommandKey = "REVIEW_MONITOR_TEST_CODEX_COMMAND" - public static let diagnosticsPathKey = "REVIEW_MONITOR_TEST_DIAGNOSTICS_PATH" - public static let reviewModeArgument = "--review-monitor-review-mode" - public static let portArgument = "--review-monitor-test-port" - public static let codexCommandArgument = "--review-monitor-test-codex-command" - public static let diagnosticsPathArgument = "--review-monitor-test-diagnostics-path" -} - struct CodexReviewStoreDiagnosticsSnapshot: Encodable { struct Run: Encodable { var status: String diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 548372c1..82f82fa9 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift @@ -24,18 +24,18 @@ extension CodexReviewStore { package var orderedReviewRuns: [ReviewRunRecord] { reviewRuns.sorted { if $0.sortOrder == $1.sortOrder { - return $0.id < $1.id + return $0.id.rawValue < $1.id.rawValue } return $0.sortOrder > $1.sortOrder } } - package func reviewRun(id: String) -> ReviewRunRecord? { + package func reviewRun(id: ReviewRunID) -> ReviewRunRecord? { reviewRuns.first(where: { $0.id == id }) } package func isCancellableReviewRun(_ runRecord: ReviewRunRecord) -> Bool { - runRecord.isTerminal == false && runRecord.cancellationRequested == false + runRecord.presentation.isCancellable && runRecord.cancellationRequested == false } package func hasCancellableReview(forChatID chatID: String) -> Bool { @@ -93,16 +93,14 @@ extension CodexReviewStore { private extension ReviewRunRecord { func matchesChatID(_ chatID: String) -> Bool { - matchesChatID(chatID, candidate: core.run.reviewThreadID) - || matchesChatID(chatID, candidate: core.run.threadID) - } - - private func matchesChatID(_ chatID: String, candidate: String?) -> Bool { - guard let candidate = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), - candidate.isEmpty == false - else { + guard let identity = core.attempt?.threadIdentity else { return false } + return matchesChatID(chatID, candidate: identity.activeTurnThreadID.rawValue) + || matchesChatID(chatID, candidate: identity.sourceThreadID.rawValue) + } + + private func matchesChatID(_ chatID: String, candidate: String) -> Bool { return candidate == chatID } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 5929fffb..f71f5956 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -1,10 +1,12 @@ import Foundation -private let networkRecoveryUnavailableMessage = "Network unavailable; waiting to reconnect." -private let networkRecoveryRestoredMessage = "Network restored; restarting review." +private struct ReviewCancellationBatchResult: Sendable { + let runID: ReviewRunID + let failure: ReviewBackendFailure? +} extension CodexReviewStore { - package func activeReviewRunIDs(for sessionID: String) -> [String] { + package func activeReviewRunIDs(for sessionID: String) -> [ReviewRunID] { orderedReviewRuns .filter { $0.sessionID == sessionID && $0.isTerminal == false } .map(\.id) @@ -15,15 +17,16 @@ extension CodexReviewStore { sessionID: String, request: CodexReviewAPI.Start.Request ) async throws -> CodexReviewAPI.Read.Result { - let runID = try beginReview(sessionID: sessionID, request: request) + let runID = try await beginReview(sessionID: sessionID, request: request) + guard let worker = runtimeState.workerTask(for: runID) else { + preconditionFailure("A newly inserted review run must publish its worker synchronously.") + } return try await withTaskCancellationHandler { _ = try await awaitReview(sessionID: sessionID, runID: runID) - await runtimeState.awaitActiveWorker(for: runID) + await worker.value return try readReview(sessionID: sessionID, runID: runID) } onCancel: { - Task { @MainActor [weak self] in - self?.runtimeState.cancelActiveWorker(for: runID) - } + worker.cancel() } } @@ -33,27 +36,28 @@ extension CodexReviewStore { request: CodexReviewAPI.Start.Request, waitTimeout: Duration ) async throws -> CodexReviewAPI.Read.Result { - let runID = try beginReview(sessionID: sessionID, request: request) + let runID = try await beginReview(sessionID: sessionID, request: request) + guard let worker = runtimeState.workerTask(for: runID) else { + preconditionFailure("A newly inserted review run must publish its worker synchronously.") + } // A timeout returns while the worker keeps running (clients re-await // by runId), but caller cancellation must cancel the worker like the // unbounded overload, or disconnected clients orphan the review. return try await withTaskCancellationHandler { try await awaitReview(sessionID: sessionID, runID: runID, timeout: waitTimeout) } onCancel: { - Task { @MainActor [weak self] in - self?.runtimeState.cancelActiveWorker(for: runID) - } + worker.cancel() } } package func awaitReview( sessionID: String?, - runID: String, + runID: ReviewRunID, timeout: Duration? = nil ) async throws -> CodexReviewAPI.Read.Result { let runRecord = try requireReviewRun(runID: runID) if let sessionID, runRecord.sessionID != sessionID { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } if runRecord.isTerminal == false { await waitForReviewTerminal(runID: runID, timeout: timeout) @@ -62,250 +66,105 @@ extension CodexReviewStore { } @discardableResult - private func beginReview( + package func beginReview( sessionID: String, request: CodexReviewAPI.Start.Request - ) throws -> String { + ) async throws -> ReviewRunID { + guard backend.acceptsNewReviewOperations else { + throw CodexReviewAPI.Error.io("The review runtime is changing accounts or stopping.") + } + try await requireReviewThreadRetentionAcceptance() + guard backend.acceptsNewReviewOperations else { + throw CodexReviewAPI.Error.io("The review runtime is changing accounts or stopping.") + } guard closedSessions.contains(sessionID) == false else { throw CodexReviewAPI.Error.invalidArguments("Review session \(sessionID) is closed.") } let validatedRequest = try request.validated() - let runID = idGenerator.next() - let createdAt = clock.now() + let runID = try ReviewRunID(validating: idGenerator.next()) let runRecord = ReviewRunRecord( id: runID, sessionID: sessionID, cwd: validatedRequest.cwd, sortOrder: nextReviewRunSortOrder(), targetSummary: validatedRequest.target.displaySummary, - core: .init( - lifecycle: .init(status: .queued), - lifecycleMessage: "Queued." - ) + core: .queued, + executionPhase: .starting ) insertReviewRun(runRecord) - markReviewRunning(runRecord, startedAt: createdAt) runtimeState.markStarting(runID) - launchReviewWorker(runID: runID, sessionID: sessionID, request: validatedRequest) + _ = launchReviewWorker( + runID: runID, + sessionID: sessionID, + request: validatedRequest + ) return runID } private func launchReviewWorker( - runID: String, + runID: ReviewRunID, sessionID: String, request: CodexReviewAPI.Start.Request - ) { - runtimeState.cancelActiveWorker(for: runID) - runtimeState.setActiveWorker( - Task { [weak self] in - await self?.runReviewWorker(runID: runID, sessionID: sessionID, request: request) - }, for: runID) - } - - private func runReviewWorker( - runID: String, - sessionID: String, - request validatedRequest: CodexReviewAPI.Start.Request - ) async { - guard let runRecord = reviewRun(id: runID) else { - runtimeState.clearStarting(runID) - runtimeState.removeActiveWorker(for: runID) - return - } - let startRequest = CodexReviewBackendModel.Review.Start( + ) -> Task { + let generation = ReviewWorkerGeneration() + let worker = ReviewStoreWorker( runID: runID, - sessionID: sessionID, - request: validatedRequest, - model: settings.effectiveModel - ) - var run: CodexReviewBackendModel.Review.Run? - do { - let backendAttempt = try await backend.startReview(startRequest) - let backendRun = backendAttempt.run - runtimeState.clearStarting(runID) - run = backendRun - if Task.isCancelled { - throw CancellationError() - } - applyBackendRun(backendRun, to: runRecord) - if let startupCancellation = runtimeState.takeStartupCancellation(for: runID) { - try? await backend.interruptReview( - backendRun, - reason: .init(message: startupCancellation.message) - ) - if runRecord.isTerminal == false { - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: startupCancellation - ) - } - } else if runRecord.cancellationRequested { - try await backend.interruptReview( - backendRun, - reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.") - ) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: runRecord.core.lifecycle.cancellation ?? .system() - ) - } - - if runRecord.isTerminal { - await backend.cleanupReview(backendRun) - runtimeState.clearReviewRunState(for: runID) - } else { - let currentRun = try await consumeReviewEvents( - for: backendAttempt, - runRecord: runRecord, - startRequest: startRequest - ) - run = currentRun - await backend.cleanupReview(currentRun) - runtimeState.clearReviewRunState(for: runID) - } - } catch let error where error is CancellationError || Task.isCancelled { - runtimeState.clearStarting(runID) - let startupCancellation = runtimeState.takeStartupCancellation(for: runID) - if let cleanupRun = runtimeState.activeRun(for: runID) ?? run { - await interruptReviewAfterTaskCancellation(cleanupRun, runRecord: runRecord) - await backend.cleanupReview(cleanupRun) - } else if runRecord.isTerminal == false || startupCancellation != nil { - try? completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: startupCancellation ?? runRecord.core.lifecycle.cancellation ?? .system() - ) - } - runtimeState.clearReviewRunState(for: runID) - } catch { - runtimeState.clearStarting(runID) - let startupCancellation = runtimeState.takeStartupCancellation(for: runID) - if let cleanupRun = runtimeState.activeRun(for: runID) ?? run { - await backend.cleanupReview(cleanupRun) - } - runtimeState.clearReviewRunState(for: runID) - if runRecord.isTerminal == false, let startupCancellation { - try? completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: startupCancellation - ) - } else if runRecord.isTerminal == false { - markReviewFailed(runRecord, message: error.localizedDescription) - } - } - runtimeState.removeActiveWorker(for: runID) - runtimeState.removeDetachedWorker(for: runID) - } - - private func interruptReviewAfterTaskCancellation(_ run: CodexReviewBackendModel.Review.Run, runRecord: ReviewRunRecord) - async - { - guard runRecord.isTerminal == false else { - return - } - let cancellation = runRecord.core.lifecycle.cancellation ?? .system() - runRecord.cancellationRequested = true - runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.lifecycleMessage = cancellation.message - runRecord.core.lifecycle.errorMessage = cancellation.message - do { - try await backend.interruptReview( - run, - reason: .init(message: cancellation.message) - ) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - } catch { - try? recordCancellationFailure( - runID: runRecord.id, - sessionID: runRecord.sessionID, - message: error.localizedDescription - ) - } - } - - private func applyBackendRun(_ backendRun: CodexReviewBackendModel.Review.Run, to runRecord: ReviewRunRecord) { - runtimeState.setActiveRun(backendRun, for: runRecord.id) - runRecord.core.run = .init( - attemptID: backendRun.attemptID, - reviewThreadID: backendRun.reviewThreadID, - threadID: backendRun.threadID, - turnID: backendRun.turnID, - model: backendRun.model - ) - writeDiagnosticsIfNeeded() - } - - private func appendRecoveryProgress(_ message: String, to runRecord: ReviewRunRecord) { - runRecord.core.lifecycleMessage = message - writeDiagnosticsIfNeeded() - } - - private func markReviewWaitingForNetworkRecovery(_ runRecord: ReviewRunRecord) { - appendRecoveryProgress(networkRecoveryUnavailableMessage, to: runRecord) - } - - private func reviewWorkerInputs(for attempt: BackendReviewAttempt) async -> ReviewWorkerInputs { - let networkMonitor = self.networkMonitor - let policy = self.networkRecoveryPolicy - let snapshots = networkMonitor.snapshots() - let tracker = ReviewNetworkStatusTracker() - let queue = ReviewWorkerInputQueue() - let signalCoordinator = ReviewNetworkSignalCoordinator( - policy: policy, - tracker: tracker, - queue: queue + startRequest: .init( + runID: runID, + sessionID: sessionID, + request: request, + model: settings.effectiveModel + ), + backend: backend, + networkMonitor: networkMonitor, + networkRecoveryPolicy: networkRecoveryPolicy, + retentionRegistry: reviewThreadRetentionRegistry, + retentionScope: currentReviewThreadRetentionScope, + sink: ReviewStoreCommitSink(store: self), + workerGeneration: generation ) - let eventSource = ReviewWorkerEventSource(queue: queue) - let networkTask = Task { - for await snapshot in snapshots { - await signalCoordinator.observe(snapshot) - } + let task = Task { @MainActor in + await worker.run() } - let initialEventSubscriptionID = await eventSource.subscribe(to: attempt) - return .init( - queue: queue, - networkStatusTracker: tracker, - eventSource: eventSource, - initialEventSubscriptionID: initialEventSubscriptionID, - networkTask: networkTask, - signalCoordinator: signalCoordinator + return runtimeState.installWorker( + for: runID, + generation: generation, + task: task ) } - package func readReview(runID: String) throws -> CodexReviewAPI.Read.Result { + package func readReview(runID: ReviewRunID) throws -> CodexReviewAPI.Read.Result { try readReview(sessionID: nil, runID: runID) } package func readReview( sessionID: String?, - runID: String + runID: ReviewRunID ) throws -> CodexReviewAPI.Read.Result { let runRecord = try requireReviewRun(runID: runID) if let sessionID, runRecord.sessionID != sessionID { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } return CodexReviewAPI.Read.Result( runID: runRecord.id, core: runRecord.core, - elapsedSeconds: elapsedSeconds(for: runRecord), - cancellable: isCancellableReviewRun(runRecord) + presentation: runRecord.presentation, + elapsedSeconds: elapsedSeconds(for: runRecord) ) } package func listReviews( cwd: String? = nil, statuses: [ReviewRunState]? = nil, - limit: Int? = nil + limit: Int? = nil, + allowedRunIDs: Set? = nil ) -> CodexReviewAPI.List.Result { - let filtered = filteredReviewRuns(cwd: cwd, statuses: statuses) + let filtered = filteredReviewRuns( + cwd: cwd, + statuses: statuses, + allowedRunIDs: allowedRunIDs + ) let clampedLimit = min(max(limit ?? 20, 1), 100) return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem)) } @@ -314,17 +173,21 @@ extension CodexReviewStore { sessionID: String?, cwd: String? = nil, statuses: [ReviewRunState]? = nil, - limit: Int? = nil + limit: Int? = nil, + allowedRunIDs: Set? = nil ) -> CodexReviewAPI.List.Result { let statusSet = statuses.map(Set.init) let filtered = orderedReviewRuns.filter { runRecord in if let sessionID, runRecord.sessionID != sessionID { return false } + if let allowedRunIDs, allowedRunIDs.contains(runRecord.id) == false { + return false + } if let cwd, runRecord.cwd != cwd { return false } - if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false { + if let statusSet, statusSet.contains(runRecord.core.status) == false { return false } return true @@ -333,20 +196,34 @@ extension CodexReviewStore { return CodexReviewAPI.List.Result(items: Array(filtered.prefix(clampedLimit)).map(makeListItem)) } - package func resolveRun(selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord { - try resolveRun(sessionID: nil, selector: selector) + package func resolveRun( + selector: CodexReviewAPI.Run.Selector, + allowedRunIDs: Set? = nil + ) throws -> ReviewRunRecord { + try resolveRun( + sessionID: nil, + selector: selector, + allowedRunIDs: allowedRunIDs + ) } - package func resolveRun(sessionID: String?, selector: CodexReviewAPI.Run.Selector) throws -> ReviewRunRecord { + package func resolveRun( + sessionID: String?, + selector: CodexReviewAPI.Run.Selector, + allowedRunIDs: Set? = nil + ) throws -> ReviewRunRecord { let statusSet = selector.statuses.map(Set.init) let matches = orderedReviewRuns.filter { runRecord in if let sessionID, runRecord.sessionID != sessionID { return false } + if let allowedRunIDs, allowedRunIDs.contains(runRecord.id) == false { + return false + } if let cwd = selector.cwd, runRecord.cwd != cwd { return false } - if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false { + if let statusSet, statusSet.contains(runRecord.core.status) == false { return false } if let runID = selector.runID, runID != runRecord.id { @@ -364,106 +241,113 @@ extension CodexReviewStore { } package func cancelReview( - runID: String, + runID: ReviewRunID, sessionID: String, cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { guard let runRecord = reviewRun(id: runID), runRecord.sessionID == sessionID else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } return try await cancelReview(runID: runID, cancellation: cancellation) } @discardableResult package func cancelReview( - runID: String, + runID: ReviewRunID, cancellation: ReviewCancellation = .system() ) async throws -> CodexReviewAPI.Cancel.Outcome { let runRecord = try requireReviewRun(runID: runID) + if let existing = runtimeState.existingCancellationOperation(for: runID) { + try await existing.completion.wait() + return try requireCancellationOutcome(runID: runID) + } guard isCancellableReviewRun(runRecord) else { - return .init(runID: runRecord.id, cancelled: false, core: runRecord.core) + return .init( + runID: runRecord.id, + cancelled: false, + core: runRecord.core, + presentation: runRecord.presentation + ) } + try Task.checkCancellation() + + let resumePhase = runRecord.executionPhase + ?? .running(attemptGeneration: 0) + runRecord.cancellationRequested = true - runRecord.core.lifecycle.cancellation = cancellation - runRecord.core.lifecycleMessage = cancellation.message - runRecord.core.lifecycle.errorMessage = cancellation.message + runRecord.pendingCancellation = cancellation + runRecord.executionPhase = .cancelling(cancellation) - if runRecord.core.lifecycle.status == .queued { + if runRecord.core.status == .queued { + if runtimeState.isStarting(runID) { + runtimeState.setStartupCancellation(cancellation, for: runID) + } try completeCancellationLocally( runID: runRecord.id, sessionID: runRecord.sessionID, cancellation: cancellation ) - return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) - } - - if runtimeState.isWaitingForNetworkRecovery(runID) { - try completeCancellationLocally( + runtimeState.cancelActiveWorker(for: runID) + return .init( runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation + cancelled: true, + core: runRecord.core, + presentation: runRecord.presentation ) - runtimeState.cancelActiveWorker(for: runID) - return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) } - if let run = runtimeState.activeRun(for: runID) { - do { - try await backend.interruptReview( - run, - reason: .init(message: cancellation.message) - ) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - runtimeState.cancelActiveWorker(for: runID) - } catch { - try recordCancellationFailure( - runID: runRecord.id, - sessionID: runRecord.sessionID, - message: error.localizedDescription - ) - throw error - } - } else if let run = runRecord.backendRun { - do { - try await backend.interruptReview( - run, - reason: .init(message: cancellation.message) - ) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - runtimeState.cancelActiveWorker(for: runID) - } catch { - try recordCancellationFailure( - runID: runRecord.id, - sessionID: runRecord.sessionID, - message: error.localizedDescription - ) - throw error + guard let workerTask = runtimeState.workerTask(for: runID), + let authority = runtimeState.cancellationAuthority(for: runID) else { + preconditionFailure("A cancellable running review must have a worker and cancellation authority.") + } + let token = ReviewCancellationOperationToken() + let completion = ReviewCancellationCompletion() + let backend = self.backend + let sink = ReviewStoreCommitSink(store: self) + let task = Task { @MainActor in + let result: ReviewCancellationOperationResult + switch authority { + case .interrupt(let attempt): + do { + try await backend.interruptReview( + attempt, + reason: .init(message: cancellation.message) + ) + sink.interruptSucceeded(runID: runID, cancellation: cancellation) + await workerTask.value + result = .completed + } catch { + let failure = cancellationBackendFailure(error) + if sink.interruptFailed(runID: runID, resumePhase: resumePhase) { + await workerTask.value + result = .completed + } else { + result = .failed(failure) + } + } + case .workerCleanup: + sink.cancelWorker(for: runID) + await workerTask.value + if sink.cancellationOutcome(runID: runID)?.presentation.status == .cancelled { + result = .completed + } else { + result = .failed(.protocolViolation( + message: "Accepted review cancellation ended without a cancelled terminal." + )) + } } - } else if runtimeState.isStarting(runID) { - runtimeState.setStartupCancellation(cancellation, for: runID) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) - } else { - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) + completion.finish(result) + sink.cancellationOperationFinished(runID: runID, token: token) } - return .init(runID: runRecord.id, cancelled: true, core: runRecord.core) + let join = ReviewCancellationJoin( + token: token, + task: task, + completion: completion + ) + runtimeState.installCancellationOperation(join, for: runID) + try await completion.wait() + return try requireCancellationOutcome(runID: runID) } @discardableResult @@ -480,37 +364,142 @@ extension CodexReviewStore { package func closeSession( _ sessionID: String, reason: ReviewCancellation = .sessionClosed() - ) async { + ) async -> CodexReviewSessionCloseResult { closedSessions.insert(sessionID) - for runID in activeReviewRunIDs(for: sessionID) { - _ = try? await cancelReview(runID: runID, cancellation: reason) - } + let memberIDs = Set( + orderedReviewRuns + .filter { $0.sessionID == sessionID } + .map(\.id) + ) + var failedRunIDs = await cancelReviewRunsConcurrently( + memberIDs.filter { reviewRun(id: $0)?.isTerminal == false }, + reason: reason + ) + for task in runtimeState.ownedTasks(for: memberIDs) { + await task.value + } + let terminalAndDrainedRunIDs = Set(memberIDs.filter { runID in + reviewRun(id: runID)?.isTerminal == true + && runtimeState.isDrained(runID) + && failedRunIDs.contains(runID) == false + }) + failedRunIDs.formUnion(memberIDs.subtracting(terminalAndDrainedRunIDs)) + return .init( + terminalAndDrainedRunIDs: terminalAndDrainedRunIDs, + failedRunIDs: failedRunIDs + ) } - package func closeActiveReviewSessions(reason: ReviewCancellation) async { + package func releaseClosedSession(_ sessionID: String) { + precondition( + closedSessions.contains(sessionID), + "Only a logically closed review session can release its store tombstone." + ) + let memberIDs = orderedReviewRuns + .filter { $0.sessionID == sessionID } + .map(\.id) + precondition( + memberIDs.allSatisfy { runID in + reviewRun(id: runID)?.isTerminal == true + && runtimeState.isDrained(runID) + }, + "A review session tombstone can only be released after every member is terminal and drained." + ) + closedSessions.remove(sessionID) + } + + @discardableResult + package func closeActiveReviewSessions( + reason: ReviewCancellation + ) async -> Bool { let runIDs = orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) - for runID in runIDs { - _ = try? await cancelReview(runID: runID, cancellation: reason) + _ = await cancelReviewRunsConcurrently(runIDs, reason: reason) + let ownedTasks = runtimeState.ownedTasks(for: Set(runIDs)) + for task in ownedTasks { + await task.value + } + return runIDs.allSatisfy { runID in + reviewRun(id: runID)?.isTerminal == true + && runtimeState.isDrained(runID) + } + } + + private func cancelReviewRunsConcurrently( + _ runIDs: S, + reason: ReviewCancellation + ) async -> Set where S.Element == ReviewRunID { + let runIDs = Array(runIDs) + let sink = ReviewStoreCommitSink(store: self) + let results = await withTaskGroup( + of: ReviewCancellationBatchResult.self, + returning: [ReviewCancellationBatchResult].self + ) { group in + for runID in runIDs { + group.addTask { [sink] in + .init( + runID: runID, + failure: await sink.cancelReview( + runID: runID, + cancellation: reason + ) + ) + } + } + var results: [ReviewCancellationBatchResult] = [] + while let result = await group.next() { + results.append(result) + } + return results + } + + var failedRunIDs: Set = [] + for result in results { + guard let failure = result.failure else { + continue + } + failedRunIDs.insert(result.runID) + sink.markWorkerFailure(failure, for: result.runID) + runtimeState.cancelActiveWorker(for: result.runID) } + return failedRunIDs } - private func requireReviewRun(runID: String) throws -> ReviewRunRecord { + private func requireReviewRun(runID: ReviewRunID) throws -> ReviewRunRecord { guard let runRecord = reviewRun(id: runID) else { - throw CodexReviewAPI.Error.runNotFound("Run \(runID) was not found.") + throw CodexReviewAPI.Error.runNotFound("Run \(runID.rawValue) was not found.") } return runRecord } - private func filteredReviewRuns(cwd: String?, statuses: [ReviewRunState]?) -> [ReviewRunRecord] { + private func requireCancellationOutcome( + runID: ReviewRunID + ) throws -> CodexReviewAPI.Cancel.Outcome { + let runRecord = try requireReviewRun(runID: runID) + return .init( + runID: runID, + cancelled: runRecord.presentation.status == .cancelled, + core: runRecord.core, + presentation: runRecord.presentation + ) + } + + private func filteredReviewRuns( + cwd: String?, + statuses: [ReviewRunState]?, + allowedRunIDs: Set? + ) -> [ReviewRunRecord] { let statusSet = statuses.map(Set.init) return orderedReviewRuns.filter { runRecord in + if let allowedRunIDs, allowedRunIDs.contains(runRecord.id) == false { + return false + } if let cwd, runRecord.cwd != cwd { return false } - if let statusSet, statusSet.contains(runRecord.core.lifecycle.status) == false { + if let statusSet, statusSet.contains(runRecord.core.status) == false { return false } return true @@ -523,16 +512,16 @@ extension CodexReviewStore { cwd: runRecord.cwd, targetSummary: runRecord.targetSummary, core: runRecord.core, - elapsedSeconds: elapsedSeconds(for: runRecord), - cancellable: isCancellableReviewRun(runRecord) + presentation: runRecord.presentation, + elapsedSeconds: elapsedSeconds(for: runRecord) ) } private func elapsedSeconds(for runRecord: ReviewRunRecord) -> Int? { - guard let startedAt = runRecord.core.lifecycle.startedAt else { + guard let startedAt = runRecord.core.startedAt else { return nil } - let end = runRecord.core.lifecycle.endedAt ?? clock.now() + let end = runRecord.core.endedAt ?? clock.now() return max(0, Int(end.timeIntervalSince(startedAt))) } @@ -541,335 +530,7 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { - runRecord.core.lifecycle.status = .running - runRecord.core.lifecycle.startedAt = startedAt - runRecord.core.lifecycleMessage = "Review started." - runRecord.core.finalReview = nil - writeDiagnosticsIfNeeded() - } - - private func markReviewFailed(_ runRecord: ReviewRunRecord, message: String) { - guard runRecord.isTerminal == false else { - return - } - let endedAt = clock.now() - runRecord.core.lifecycle.status = .failed - runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.lifecycle.errorMessage = message - runRecord.core.lifecycleMessage = message - runRecord.core.finalReview = nil - writeDiagnosticsIfNeeded() - } - - private func consumeReviewEvents( - for initialAttempt: BackendReviewAttempt, - runRecord: ReviewRunRecord, - startRequest: CodexReviewBackendModel.Review.Start - ) async throws -> CodexReviewBackendModel.Review.Run { - let inputs = await reviewWorkerInputs(for: initialAttempt) - defer { - inputs.cancel() - } - var recoveryState = ReviewNetworkRecoveryLoopState(currentRun: initialAttempt.run) - var activeEventSubscriptionID: Int? = inputs.initialEventSubscriptionID - while let input = await inputs.next() { - if runRecord.isTerminal { - return recoveryState.currentRun - } - switch input { - case .reviewEvent(let event): - guard activeEventSubscriptionID == event.subscriptionID, - recoveryState.shouldConsumeEvent(from: event.subscriptionRun) - else { - continue - } - recoveryState.currentRun = handleReviewEvent( - event.event, - runRecord: runRecord, - currentRun: recoveryState.currentRun - ) - if runRecord.isTerminal { - return recoveryState.currentRun - } - case .reviewEventsFinished(let finishedRun): - guard activeEventSubscriptionID == finishedRun.subscriptionID else { - continue - } - if recoveryState.shouldIgnoreFinishedEvent(for: finishedRun.run) { - continue - } - if try handleReviewEventsFinished( - runRecord: runRecord, - isWaitingForNetworkRecovery: recoveryState.isWaitingForNetworkRecovery - ) { - return recoveryState.currentRun - } - case .reviewEventsFailed(let failedRun): - guard activeEventSubscriptionID == failedRun.subscriptionID, - recoveryState.shouldConsumeEvent(from: failedRun.run) - else { - continue - } - if failedRun.failure.isCancellation { - throw CancellationError() - } - if await inputs.networkStatusTracker.currentStatus() != .satisfied { - recoveryState.recordPendingOutageStreamFailure(failedRun.failure) - activeEventSubscriptionID = nil - await inputs.cancelActiveEventSubscription() - continue - } - try throwReviewEventStreamFailure(failedRun.failure) - case .networkSnapshot(let snapshot, let recoveryGeneration): - if let pendingFailure = recoveryState.takePendingOutageStreamFailureAfterTransientRecovery( - snapshot - ) { - try throwReviewEventStreamFailure(pendingFailure) - } - switch recoveryState.networkSnapshotEffect(snapshot, recoveryGeneration: recoveryGeneration) { - case .none: - continue - case .restartSettling: - appendRecoveryProgress(networkRecoveryRestoredMessage, to: runRecord) - } - case .networkRecoverySettled(let recoveryGeneration): - guard - recoveryState.shouldRestartReviewAfterRecoverySettle( - recoveryGeneration: recoveryGeneration - ) - else { - continue - } - switch try await restartReviewAfterNetworkRestore( - runRecord: runRecord, - startRequest: startRequest, - inputs: inputs, - preparedRestartToken: recoveryState.preparedRestartToken - ) { - case .continueWaiting: - recoveryState.markWaitingForNetworkRecovery() - continue - case .finished: - runtimeState.clearWaitingForNetworkRecovery(runRecord.id) - return recoveryState.currentRun - case .recovered(let recoveredAttempt): - let recoveredRun = recoveredAttempt.run - applyBackendRun(recoveredRun, to: runRecord) - recoveryState.markRecovered(with: recoveredRun) - runtimeState.clearWaitingForNetworkRecovery(runRecord.id) - activeEventSubscriptionID = await inputs.subscribe(to: recoveredAttempt) - } - case .networkOutageConfirmed: - guard recoveryState.isWaitingForNetworkRecovery == false, - runRecord.isTerminal == false, - runRecord.cancellationRequested == false, - await inputs.networkStatusTracker.currentStatus() != .satisfied - else { - continue - } - recoveryState.markWaitingForNetworkRecovery() - markReviewWaitingForNetworkRecovery(runRecord) - runtimeState.markWaitingForNetworkRecovery(runRecord.id) - activeEventSubscriptionID = nil - await inputs.cancelActiveEventSubscription() - let restartToken = try await backend.prepareReviewRestart(recoveryState.currentRun) - recoveryState.markPreparedRestartToken(restartToken) - } - } - - if Task.isCancelled { - throw CancellationError() - } - if runRecord.isTerminal == false { - if completePendingCancellationIfNeeded(for: runRecord) { - return recoveryState.currentRun - } - markReviewFailed(runRecord, message: "Review completed without review output.") - } - return recoveryState.currentRun - } - - private func handleReviewEventsFinished( - runRecord: ReviewRunRecord, - isWaitingForNetworkRecovery: Bool - ) throws -> Bool { - if Task.isCancelled { - throw CancellationError() - } - - if isWaitingForNetworkRecovery { - return runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord) - } - - if runRecord.isTerminal == false { - if completePendingCancellationIfNeeded(for: runRecord) { - return true - } - markReviewFailed(runRecord, message: "Review completed without review output.") - } - return true - } - - private func throwReviewEventStreamFailure(_ failure: ReviewWorkerEventStreamFailure) throws -> Never { - switch failure { - case .cancelled: - throw CancellationError() - case .failed(let message): - throw ReviewWorkerInputQueueError(message: message) - } - } - - private func restartReviewAfterNetworkRestore( - runRecord: ReviewRunRecord, - startRequest: CodexReviewBackendModel.Review.Start, - inputs: ReviewWorkerInputs, - preparedRestartToken: CodexReviewBackendModel.Review.RestartToken? - ) async throws -> NetworkRestoreRestartResult { - if runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord) { - return .finished - } - if Task.isCancelled { - throw CancellationError() - } - if runRecord.isTerminal || completePendingCancellationIfNeeded(for: runRecord) { - return .finished - } - guard await inputs.networkStatusTracker.currentStatus() == .satisfied else { - return .continueWaiting - } - guard let preparedRestartToken else { - return .continueWaiting - } - let recoveredAttempt = try await backend.restartPreparedReview( - preparedRestartToken, - request: startRequest - ) - let recoveredRun = recoveredAttempt.run - if try await stopRecoveredRunIfReviewShouldNotResume(recoveredRun, runRecord: runRecord) { - return .finished - } - return .recovered(recoveredAttempt) - } - - private func stopRecoveredRunIfReviewShouldNotResume( - _ recoveredRun: CodexReviewBackendModel.Review.Run, - runRecord: ReviewRunRecord - ) async throws -> Bool { - if Task.isCancelled { - try? await backend.interruptReview( - recoveredRun, - reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.") - ) - await backend.cleanupReview(recoveredRun) - throw CancellationError() - } - - if runRecord.isTerminal { - if runRecord.core.lifecycle.status == .cancelled { - try? await backend.interruptReview( - recoveredRun, - reason: .init(message: runRecord.core.lifecycle.cancellation?.message ?? "Cancellation requested.") - ) - } - await backend.cleanupReview(recoveredRun) - return true - } - - guard runRecord.cancellationRequested else { - return false - } - - let cancellation = runRecord.core.lifecycle.cancellation ?? .system() - do { - try await backend.interruptReview(recoveredRun, reason: .init(message: cancellation.message)) - try completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - } catch { - await backend.cleanupReview(recoveredRun) - try? recordCancellationFailure( - runID: runRecord.id, - sessionID: runRecord.sessionID, - message: error.localizedDescription - ) - throw error - } - await backend.cleanupReview(recoveredRun) - return true - } - - private func handleReviewEvent( - _ event: CodexReviewBackendModel.Review.Event, - runRecord: ReviewRunRecord, - currentRun: CodexReviewBackendModel.Review.Run - ) -> CodexReviewBackendModel.Review.Run { - guard runRecord.isTerminal == false else { - return currentRun - } - if event.completesReviewRun, completePendingCancellationIfNeeded(for: runRecord) { - writeDiagnosticsIfNeeded() - return currentRun - } - var updatedRun = currentRun - switch event { - case .started(let turnID, let reviewThreadID, let model): - runRecord.core.run.turnID = turnID - runRecord.core.run.reviewThreadID = reviewThreadID ?? runRecord.core.run.reviewThreadID - runRecord.core.run.model = model ?? runRecord.core.run.model - updatedRun.turnID = turnID - updatedRun.reviewThreadID = reviewThreadID ?? updatedRun.reviewThreadID - updatedRun.model = model ?? updatedRun.model - runtimeState.setActiveRun(updatedRun, for: runRecord.id) - runRecord.core.lifecycleMessage = "Review started." - case .completed(let completion): - completeReview(runRecord, finalReview: completion.finalReview) - case .failed(let message): - markReviewFailed(runRecord, message: message) - case .cancelled(let message): - let cancellation = runRecord.core.lifecycle.cancellation ?? .system(message: message) - try? completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - } - writeDiagnosticsIfNeeded() - return updatedRun - } - - private func completePendingCancellationIfNeeded(for runRecord: ReviewRunRecord) -> Bool { - guard runRecord.cancellationRequested else { - return false - } - let cancellation = runRecord.core.lifecycle.cancellation ?? .system() - try? completeCancellationLocally( - runID: runRecord.id, - sessionID: runRecord.sessionID, - cancellation: cancellation - ) - return true - } - - private func completeReview(_ runRecord: ReviewRunRecord, finalReview: String?) { - guard runRecord.isTerminal == false else { - return - } - guard let finalReview = finalReview?.nilIfEmpty else { - markReviewFailed(runRecord, message: "Review completed without review output.") - return - } - let endedAt = clock.now() - runRecord.core.lifecycle.status = .succeeded - runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.lifecycleMessage = "Review completed." - runRecord.core.finalReview = finalReview - writeDiagnosticsIfNeeded() - } - - private func waitForReviewTerminal(runID: String, timeout: Duration?) async { + private func waitForReviewTerminal(runID: ReviewRunID, timeout: Duration?) async { guard let runRecord = reviewRun(id: runID), runRecord.isTerminal == false else { @@ -886,516 +547,20 @@ extension CodexReviewStore { } } -private extension CodexReviewBackendModel.Review.Event { - var completesReviewRun: Bool { - switch self { - case .completed, .failed, .cancelled: - true - case .started: - false - } - } -} - -private extension ReviewRunRecord { - var backendRun: CodexReviewBackendModel.Review.Run? { - guard let threadID = core.run.threadID else { - return nil - } - return .init( - attemptID: core.run.attemptID ?? "attempt-1", - threadID: threadID, - turnID: core.run.turnID, - reviewThreadID: core.run.reviewThreadID, - model: core.run.model - ) - } - -} - -private struct ReviewWorkerReviewEvent: Sendable { - var subscriptionID: Int - var subscriptionRun: CodexReviewBackendModel.Review.Run - var event: CodexReviewBackendModel.Review.Event -} - -private struct ReviewWorkerEventStreamFinished: Sendable { - var subscriptionID: Int - var run: CodexReviewBackendModel.Review.Run -} - -private struct ReviewWorkerEventStreamFailed: Sendable { - var subscriptionID: Int - var run: CodexReviewBackendModel.Review.Run - var failure: ReviewWorkerEventStreamFailure -} - -private enum ReviewWorkerEventStreamFailure: Sendable { - case cancelled - case failed(String) - - var isCancellation: Bool { - switch self { - case .cancelled: - true - case .failed: - false - } - } -} - -private enum ReviewWorkerInput: Sendable { - case reviewEvent(ReviewWorkerReviewEvent) - case reviewEventsFinished(ReviewWorkerEventStreamFinished) - case reviewEventsFailed(ReviewWorkerEventStreamFailed) - case networkSnapshot(CodexReviewNetworkSnapshot, recoveryGeneration: Int) - case networkOutageConfirmed - case networkRecoverySettled(recoveryGeneration: Int) -} - -private enum NetworkRestoreRestartResult { - case continueWaiting - case finished - case recovered(BackendReviewAttempt) -} - -private enum ReviewNetworkSnapshotEffect { - case none - case restartSettling -} - -private struct ReviewNetworkRecoveryLoopState { - var currentRun: CodexReviewBackendModel.Review.Run - private(set) var isWaitingForNetworkRecovery = false - private(set) var preparedRestartToken: CodexReviewBackendModel.Review.RestartToken? - private var isSettlingForNetworkRecovery = false - private var recoverySettleGeneration: Int? - private var pendingOutageStreamFailure: ReviewWorkerEventStreamFailure? - init(currentRun: CodexReviewBackendModel.Review.Run) { - self.currentRun = currentRun - } - - mutating func markWaitingForNetworkRecovery() { - isWaitingForNetworkRecovery = true - isSettlingForNetworkRecovery = false - recoverySettleGeneration = nil - pendingOutageStreamFailure = nil - } - - mutating func markPreparedRestartToken(_ token: CodexReviewBackendModel.Review.RestartToken) { - preparedRestartToken = token - } - - mutating func markRecovered(with run: CodexReviewBackendModel.Review.Run) { - currentRun = run - isWaitingForNetworkRecovery = false - preparedRestartToken = nil - isSettlingForNetworkRecovery = false - recoverySettleGeneration = nil - pendingOutageStreamFailure = nil - } - - mutating func recordPendingOutageStreamFailure(_ failure: ReviewWorkerEventStreamFailure) { - pendingOutageStreamFailure = failure - } - - mutating func takePendingOutageStreamFailureAfterTransientRecovery( - _ snapshot: CodexReviewNetworkSnapshot - ) -> ReviewWorkerEventStreamFailure? { - guard snapshot.status == .satisfied, - isWaitingForNetworkRecovery == false - else { - return nil - } - defer { - pendingOutageStreamFailure = nil - } - return pendingOutageStreamFailure - } - - func shouldIgnoreFinishedEvent(for run: CodexReviewBackendModel.Review.Run) -> Bool { - isWaitingForNetworkRecovery || run.attemptID != currentRun.attemptID - } - - func shouldRestartReviewAfterRecoverySettle(recoveryGeneration: Int) -> Bool { - isWaitingForNetworkRecovery - && isSettlingForNetworkRecovery - && recoverySettleGeneration == recoveryGeneration - && preparedRestartToken != nil - } - - func shouldConsumeEvent(from run: CodexReviewBackendModel.Review.Run) -> Bool { - isWaitingForNetworkRecovery == false && run.attemptID == currentRun.attemptID - } - - mutating func networkSnapshotEffect( - _ snapshot: CodexReviewNetworkSnapshot, - recoveryGeneration: Int - ) -> ReviewNetworkSnapshotEffect { - guard isWaitingForNetworkRecovery else { - return .none - } - guard snapshot.status == .satisfied else { - isSettlingForNetworkRecovery = false - recoverySettleGeneration = nil - return .none - } - guard isSettlingForNetworkRecovery == false else { - recoverySettleGeneration = recoveryGeneration - return .none - } - isSettlingForNetworkRecovery = true - recoverySettleGeneration = recoveryGeneration - return .restartSettling - } -} - -private struct ReviewWorkerInputs { - var queue: ReviewWorkerInputQueue - var networkStatusTracker: ReviewNetworkStatusTracker - var eventSource: ReviewWorkerEventSource - var initialEventSubscriptionID: Int - var networkTask: Task - var signalCoordinator: ReviewNetworkSignalCoordinator - - func next() async -> ReviewWorkerInput? { - await queue.next() - } - - func subscribe(to attempt: BackendReviewAttempt) async -> Int { - await eventSource.subscribe(to: attempt) - } - - func cancelActiveEventSubscription() async { - await eventSource.cancelActiveSubscription() - } - - func cancel() { - networkTask.cancel() - Task { - await eventSource.cancel() - await signalCoordinator.cancel() - await queue.finish() - } +func cancellationBackendFailure(_ error: any Error) -> ReviewBackendFailure { + if let failure = error as? ReviewBackendFailure { + return failure } + return .protocolViolation( + message: "Review backend interruptReview threw an unsupported error: \(error.localizedDescription)" + ) } -private actor ReviewWorkerInputQueue { - private enum Delivery { - case input(ReviewWorkerInput) - case finished - } - - private var bufferedInputs: [ReviewWorkerInput] = [] - private var isFinished = false - private var waiters: [UUID: CheckedContinuation] = [:] - - func next() async -> ReviewWorkerInput? { - switch await nextDelivery() { - case .input(let input): - return input - case .finished: - return nil - } - } - - func send(_ input: ReviewWorkerInput) { - guard isFinished == false else { - return - } - if let waiterID = waiters.keys.first, - let waiter = waiters.removeValue(forKey: waiterID) - { - waiter.resume(returning: .input(input)) - } else { - bufferedInputs.append(input) - } - } - - func finish() { - guard isFinished == false else { - return - } - isFinished = true - resumeWaitersForFinishIfReady() - } - - private func nextDelivery() async -> Delivery { - if bufferedInputs.isEmpty == false { - let input = bufferedInputs.removeFirst() - resumeWaitersForFinishIfReady() - return .input(input) - } - if isFinished { - return .finished - } - let waiterID = UUID() - return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if bufferedInputs.isEmpty == false { - let input = bufferedInputs.removeFirst() - resumeWaitersForFinishIfReady() - continuation.resume(returning: .input(input)) - } else if isFinished { - continuation.resume(returning: .finished) - } else { - waiters[waiterID] = continuation - } - } - } onCancel: { - Task { - await self.cancelWaiter(id: waiterID) - } - } - } - - private func cancelWaiter(id: UUID) { - waiters.removeValue(forKey: id)?.resume(returning: .finished) - } - - private func resumeWaitersForFinishIfReady() { - guard bufferedInputs.isEmpty, isFinished else { - return - } - let waiters = Array(waiters.values) - self.waiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume(returning: .finished) - } - } -} - -private struct ReviewWorkerInputQueueError: LocalizedError, Sendable { - var message: String - - var errorDescription: String? { - message - } -} - -private actor ReviewWorkerEventSource { - private let queue: ReviewWorkerInputQueue - private var eventTasks: [Int: Task] = [:] - private var subscriptionID = 0 - private var activeSubscriptionID: Int? - - init(queue: ReviewWorkerInputQueue) { - self.queue = queue - } - - func subscribe(to attempt: BackendReviewAttempt) -> Int { - subscriptionID += 1 - let subscriptionID = subscriptionID - activeSubscriptionID = subscriptionID - cancelEventTasks() - let run = attempt.run - let events = attempt.events - eventTasks[subscriptionID] = Task { - do { - while let event = try await events.next() { - guard Task.isCancelled == false else { - return - } - await self.yieldReviewEvent(event, run: run, subscriptionID: subscriptionID) - } - await self.yieldEventsFinished(run: run, subscriptionID: subscriptionID) - } catch { - await self.yieldEventsFailed(error, run: run, subscriptionID: subscriptionID) - } - } - return subscriptionID - } - - func cancelActiveSubscription() { - subscriptionID += 1 - activeSubscriptionID = nil - cancelEventTasks() - } - - func cancel() { - subscriptionID += 1 - activeSubscriptionID = nil - cancelEventTasks() - } - - private func cancelEventTasks() { - for task in eventTasks.values { - task.cancel() - } - eventTasks.removeAll(keepingCapacity: true) - } - - private func yieldReviewEvent( - _ event: CodexReviewBackendModel.Review.Event, - run: CodexReviewBackendModel.Review.Run, - subscriptionID: Int - ) async { - guard activeSubscriptionID == subscriptionID, - eventTasks[subscriptionID] != nil - else { - return - } - await queue.send( - .reviewEvent( - .init( - subscriptionID: subscriptionID, - subscriptionRun: run, - event: event - ))) - } - - private func yieldEventsFinished(run: CodexReviewBackendModel.Review.Run, subscriptionID: Int) async { - guard activeSubscriptionID == subscriptionID, - eventTasks.removeValue(forKey: subscriptionID) != nil - else { - return - } - await queue.send( - .reviewEventsFinished( - .init( - subscriptionID: subscriptionID, - run: run - ))) - } - - private func yieldEventsFailed( - _ error: any Error, - run: CodexReviewBackendModel.Review.Run, - subscriptionID: Int - ) async { - guard eventTasks.removeValue(forKey: subscriptionID) != nil else { - return - } - guard activeSubscriptionID == subscriptionID else { - return - } - await queue.send( - .reviewEventsFailed( - .init( - subscriptionID: subscriptionID, - run: run, - failure: error is CancellationError ? .cancelled : .failed(error.localizedDescription) - ))) - } -} - -private actor ReviewNetworkStatusTracker { - private var latest: CodexReviewNetworkSnapshot = .satisfied() - - func update(_ snapshot: CodexReviewNetworkSnapshot) { - latest = snapshot - } - - func currentStatus() -> CodexReviewNetworkStatus { - latest.status - } - - func latestSnapshot() -> CodexReviewNetworkSnapshot { - latest - } -} - -private actor ReviewNetworkSignalCoordinator { - private let policy: CodexReviewNetworkRecoveryPolicy - private let tracker: ReviewNetworkStatusTracker - private let queue: ReviewWorkerInputQueue - private var outageTask: Task? - private var outageGeneration = 0 - private var recoveryTask: Task? - private var recoveryGeneration = 0 - - init( - policy: CodexReviewNetworkRecoveryPolicy, - tracker: ReviewNetworkStatusTracker, - queue: ReviewWorkerInputQueue - ) { - self.policy = policy - self.tracker = tracker - self.queue = queue - } - - func observe(_ snapshot: CodexReviewNetworkSnapshot) async { - await tracker.update(snapshot) - switch snapshot.status { - case .satisfied: - outageGeneration += 1 - outageTask?.cancel() - outageTask = nil - recoveryGeneration += 1 - let recoveryGeneration = recoveryGeneration - recoveryTask?.cancel() - recoveryTask = nil - await queue.send(.networkSnapshot(snapshot, recoveryGeneration: recoveryGeneration)) - scheduleRecoveryConfirmationIfNeeded(generation: recoveryGeneration) - case .unsatisfied, .requiresConnection: - recoveryGeneration += 1 - let recoveryGeneration = recoveryGeneration - recoveryTask?.cancel() - recoveryTask = nil - await queue.send(.networkSnapshot(snapshot, recoveryGeneration: recoveryGeneration)) - scheduleOutageConfirmationIfNeeded() - } - } - - func cancel() { - outageTask?.cancel() - outageTask = nil - recoveryTask?.cancel() - recoveryTask = nil - } - - private func scheduleOutageConfirmationIfNeeded() { - guard outageTask == nil else { - return - } - let policy = policy - outageGeneration += 1 - let generation = outageGeneration - outageTask = Task { - do { - try await policy.sleep(policy.outageDebounce) - } catch { - return - } - await self.confirmOutageIfCurrent(generation: generation) - } - } - - private func confirmOutageIfCurrent(generation: Int) async { - guard generation == outageGeneration else { - return - } - let latest = await tracker.latestSnapshot() - guard latest.status != .satisfied else { - return - } - await queue.send(.networkOutageConfirmed) - } - - private func scheduleRecoveryConfirmationIfNeeded(generation: Int) { - guard recoveryTask == nil else { - return - } - let policy = policy - recoveryTask = Task { - do { - try await policy.sleep(policy.recoverySettle) - } catch { - return - } - await self.confirmRecoveryIfCurrent(generation: generation) - } - } - - private func confirmRecoveryIfCurrent(generation: Int) async { - guard generation == recoveryGeneration else { - return - } - recoveryTask = nil - let latest = await tracker.latestSnapshot() - guard latest.status == .satisfied else { - return +extension ReviewBackendTerminal { + var isConnectionTermination: Bool { + guard case .failed(.connectionTerminated) = self else { + return false } - await queue.send(.networkRecoverySettled(recoveryGeneration: generation)) + return true } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift index ae64d2cb..e6aeb7c0 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -1,162 +1,394 @@ import Foundation +import Synchronization package struct CodexReviewRuntimeRunState: Sendable { - package let activeRun: CodexReviewBackendModel.Review.Run? + package let activeAttempt: ReviewAttempt? package let hasActiveWorker: Bool - package let hasDetachedWorker: Bool package let isWaitingForNetworkRecovery: Bool } +package struct CodexReviewSessionCloseResult: Equatable, Sendable { + package let terminalAndDrainedRunIDs: Set + package let failedRunIDs: Set + + package init( + terminalAndDrainedRunIDs: Set, + failedRunIDs: Set = [] + ) { + self.terminalAndDrainedRunIDs = terminalAndDrainedRunIDs + self.failedRunIDs = failedRunIDs + } +} + +struct ReviewWorkerGeneration: Hashable, Sendable { + let rawValue: UUID + + init() { + rawValue = UUID() + } +} + +struct ReviewCancellationOperationToken: Hashable, Sendable { + let rawValue: UUID + + init() { + rawValue = UUID() + } +} + +enum ReviewCancellationOperationResult: Sendable { + case completed + case failed(ReviewBackendFailure) +} + +enum ReviewWorkerCancellationAuthority: Sendable { + case interrupt(ReviewAttempt) + case workerCleanup +} + +private enum ReviewCancellationWaitResult: Sendable { + case operation(ReviewCancellationOperationResult) + case callerCancelled +} + +private final class ReviewCancellationWaiter: Sendable { + private enum State: Sendable { + case idle + case waiting(CheckedContinuation) + case finished(ReviewCancellationWaitResult) + } + + private let state = Mutex(.idle) + + func wait() async -> ReviewCancellationWaitResult { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> ReviewCancellationWaitResult? in + switch state { + case .idle where Task.isCancelled: + state = .finished(.callerCancelled) + return .callerCancelled + case .idle: + state = .waiting(continuation) + return nil + case .waiting: + preconditionFailure("A cancellation completion waiter is single-consumer.") + case .finished(let result): + return result + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } onCancel: { + finish(.callerCancelled) + } + } + + func finish(_ result: ReviewCancellationWaitResult) { + let continuation = state.withLock { state + -> CheckedContinuation? in + switch state { + case .idle: + state = .finished(result) + return nil + case .waiting(let continuation): + state = .finished(result) + return continuation + case .finished: + return nil + } + } + continuation?.resume(returning: result) + } +} + +final class ReviewCancellationCompletion: Sendable { + private struct State: Sendable { + var result: ReviewCancellationOperationResult? + var waiters: [ReviewCancellationWaiter] = [] + } + + private let state = Mutex(State()) + + func wait() async throws { + let waiter = ReviewCancellationWaiter() + let immediate = state.withLock { state -> ReviewCancellationOperationResult? in + if let result = state.result { + return result + } + state.waiters.append(waiter) + return nil + } + let waitResult: ReviewCancellationWaitResult + if let immediate { + waitResult = .operation(immediate) + } else { + waitResult = await waiter.wait() + } + switch waitResult { + case .operation(.completed): + return + case .operation(.failed(let failure)): + throw failure + case .callerCancelled: + throw CancellationError() + } + } + + func finish(_ result: ReviewCancellationOperationResult) { + let waiters = state.withLock { state -> [ReviewCancellationWaiter] in + guard state.result == nil else { + preconditionFailure("A cancellation operation completion can only resolve once.") + } + state.result = result + let waiters = state.waiters + state.waiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.finish(.operation(result)) + } + } + + var resultIfFinished: ReviewCancellationOperationResult? { + state.withLock { $0.result } + } +} + +struct ReviewCancellationJoin: Sendable { + let token: ReviewCancellationOperationToken + let task: Task + let completion: ReviewCancellationCompletion +} + @MainActor -final class CodexReviewStoreRuntimeState { - typealias BackendReviewRun = CodexReviewBackendModel.Review.Run +final class ReviewStoreRuntime { + private struct WorkerEntry { + let generation: ReviewWorkerGeneration + let task: Task + } + + private struct CancellationEntry { + let join: ReviewCancellationJoin + } - private var activeRuns: [String: BackendReviewRun] = [:] - private var reviewRecoveryWaitingRunIDs: Set = [] - private var startingRunIDs: Set = [] - private var startupCancellations: [String: ReviewCancellation] = [:] - private var reviewWorkerTasks: [String: Task] = [:] - private var runtimeStopDetachedReviewWorkerTasks: [String: Task] = [:] + private var activeAttempts: [ReviewRunID: ReviewAttempt] = [:] + private var reviewRecoveryWaitingRunIDs: Set = [] + private var startingRunIDs: Set = [] + private var startupCancellations: [ReviewRunID: ReviewCancellation] = [:] + private var cancellationAuthorities: [ReviewRunID: ReviewWorkerCancellationAuthority] = [:] + private var workers: [ReviewRunID: WorkerEntry] = [:] + private var cancellationOperations: [ReviewRunID: CancellationEntry] = [:] + + isolated deinit { + signalCancellation() + } - func runState(for runID: String) -> CodexReviewRuntimeRunState { + func runState(for runID: ReviewRunID) -> CodexReviewRuntimeRunState { CodexReviewRuntimeRunState( - activeRun: activeRuns[runID], - hasActiveWorker: reviewWorkerTasks[runID] != nil, - hasDetachedWorker: runtimeStopDetachedReviewWorkerTasks[runID] != nil, + activeAttempt: activeAttempts[runID], + hasActiveWorker: workers[runID] != nil, isWaitingForNetworkRecovery: reviewRecoveryWaitingRunIDs.contains(runID) ) } - func activeRun(for runID: String) -> BackendReviewRun? { - activeRuns[runID] + func activeAttempt(for runID: ReviewRunID) -> ReviewAttempt? { + activeAttempts[runID] } - func setActiveRun(_ run: BackendReviewRun, for runID: String) { - activeRuns[runID] = run + func setActiveAttempt(_ attempt: ReviewAttempt, for runID: ReviewRunID) { + activeAttempts[runID] = attempt } - func removeActiveRun(for runID: String) { - activeRuns.removeValue(forKey: runID) + func removeActiveAttempt(for runID: ReviewRunID) { + activeAttempts.removeValue(forKey: runID) } - func recoveryWaitingRuns() -> [BackendReviewRun] { + func recoveryWaitingAttempts() -> [ReviewAttempt] { reviewRecoveryWaitingRunIDs - .sorted() - .compactMap { activeRuns[$0] } + .sorted { $0.rawValue < $1.rawValue } + .compactMap { activeAttempts[$0] } } - func isWaitingForNetworkRecovery(_ runID: String) -> Bool { + func isWaitingForNetworkRecovery(_ runID: ReviewRunID) -> Bool { reviewRecoveryWaitingRunIDs.contains(runID) } - func markWaitingForNetworkRecovery(_ runID: String) { + func markWaitingForNetworkRecovery(_ runID: ReviewRunID) { reviewRecoveryWaitingRunIDs.insert(runID) } - func clearWaitingForNetworkRecovery(_ runID: String) { + func clearWaitingForNetworkRecovery(_ runID: ReviewRunID) { reviewRecoveryWaitingRunIDs.remove(runID) } - func markStarting(_ runID: String) { + func markStarting(_ runID: ReviewRunID) { startingRunIDs.insert(runID) } - func clearStarting(_ runID: String) { + func clearStarting(_ runID: ReviewRunID) { startingRunIDs.remove(runID) } - func isStarting(_ runID: String) -> Bool { + func isStarting(_ runID: ReviewRunID) -> Bool { startingRunIDs.contains(runID) } - func setStartupCancellation(_ cancellation: ReviewCancellation, for runID: String) { + func setStartupCancellation(_ cancellation: ReviewCancellation, for runID: ReviewRunID) { startupCancellations[runID] = cancellation } - func takeStartupCancellation(for runID: String) -> ReviewCancellation? { + func startupCancellation(for runID: ReviewRunID) -> ReviewCancellation? { + startupCancellations[runID] + } + + func takeStartupCancellation(for runID: ReviewRunID) -> ReviewCancellation? { startupCancellations.removeValue(forKey: runID) } - func setActiveWorker(_ task: Task, for runID: String) { - reviewWorkerTasks[runID] = task + func setCancellationAuthority( + _ authority: ReviewWorkerCancellationAuthority, + for runID: ReviewRunID + ) { + cancellationAuthorities[runID] = authority } - func cancelActiveWorker(for runID: String) { - reviewWorkerTasks[runID]?.cancel() + func cancellationAuthority(for runID: ReviewRunID) -> ReviewWorkerCancellationAuthority? { + cancellationAuthorities[runID] } - func removeActiveWorker(for runID: String) { - reviewWorkerTasks.removeValue(forKey: runID) + @discardableResult + func installWorker( + for runID: ReviewRunID, + generation: ReviewWorkerGeneration, + task: Task + ) -> Task { + precondition( + workers[runID] == nil, + "A review run can only own one worker generation." + ) + workers[runID] = WorkerEntry(generation: generation, task: task) + return task } - func awaitActiveWorker(for runID: String) async { - let task = reviewWorkerTasks[runID] - await task?.value + func workerTask(for runID: ReviewRunID) -> Task? { + workers[runID]?.task } - func cancelAndDetachActiveWorkerForRuntimeStop(runID: String) { - if let task = reviewWorkerTasks.removeValue(forKey: runID) { - task.cancel() - runtimeStopDetachedReviewWorkerTasks[runID] = task + func cancelActiveWorker(for runID: ReviewRunID) { + workers[runID]?.task.cancel() + } + + func workerFinished(for runID: ReviewRunID, generation: ReviewWorkerGeneration) { + if workers[runID]?.generation == generation { + workers.removeValue(forKey: runID) } } - func removeDetachedWorker(for runID: String) { - runtimeStopDetachedReviewWorkerTasks.removeValue(forKey: runID) + func awaitActiveWorker(for runID: ReviewRunID) async { + await workerTask(for: runID)?.value } func activeWorkerTasks() -> [Task] { - Array(reviewWorkerTasks.values) + workers.values.map(\.task) } - func detachedWorkerTasks() -> [Task] { - Array(runtimeStopDetachedReviewWorkerTasks.values) + func allWorkerTasks() -> [Task] { + activeWorkerTasks() } - func allWorkerTasks() -> [Task] { - activeWorkerTasks() + detachedWorkerTasks() + func existingCancellationOperation(for runID: ReviewRunID) -> ReviewCancellationJoin? { + cancellationOperations[runID]?.join } - func clearRuntimeStopState(for runID: String) { - removeActiveRun(for: runID) - clearWaitingForNetworkRecovery(runID) - clearStarting(runID) - _ = takeStartupCancellation(for: runID) + func installCancellationOperation( + _ join: ReviewCancellationJoin, + for runID: ReviewRunID + ) { + precondition( + cancellationOperations[runID] == nil, + "A review run can only own one accepted cancellation operation." + ) + cancellationOperations[runID] = CancellationEntry(join: join) + } + + func cancellationOperationFinished( + for runID: ReviewRunID, + token: ReviewCancellationOperationToken + ) { + guard cancellationOperations[runID]?.join.token == token else { + return + } + cancellationOperations.removeValue(forKey: runID) + } + + func cancellationOperationTasks() -> [Task] { + cancellationOperations.values.map(\.join.task) + } + + func ownedTasks(for runIDs: Set) -> [Task] { + var tasks: [Task] = [] + for runID in runIDs { + if let task = workerTask(for: runID) { + tasks.append(task) + } + if let task = cancellationOperations[runID]?.join.task { + tasks.append(task) + } + } + return tasks } - func clearReviewRunState(for runID: String) { - removeActiveRun(for: runID) + func isDrained(_ runID: ReviewRunID) -> Bool { + workers[runID] == nil + && cancellationOperations[runID] == nil + && activeAttempts[runID] == nil + } + + func clearReviewRunState(for runID: ReviewRunID) { + removeActiveAttempt(for: runID) clearWaitingForNetworkRecovery(runID) + clearStarting(runID) + _ = takeStartupCancellation(for: runID) + cancellationAuthorities.removeValue(forKey: runID) } - func cancelAllWorkers() { + func signalCancellation() { for task in allWorkerTasks() { task.cancel() } + for task in cancellationOperationTasks() { + task.cancel() + } } func cancelAndDrainAllWorkersForTesting() async { - let tasks = allWorkerTasks() - for task in tasks { - task.cancel() - } + signalCancellation() + let tasks = allWorkerTasks() + cancellationOperationTasks() for task in tasks { await task.value } } func clearForTesting() { - reviewWorkerTasks.removeAll(keepingCapacity: false) - runtimeStopDetachedReviewWorkerTasks.removeAll(keepingCapacity: false) + precondition( + allWorkerTasks().allSatisfy(\.isCancelled), + "Testing cleanup must signal every review worker before clearing runtime ownership." + ) + workers.removeAll(keepingCapacity: false) + cancellationOperations.removeAll(keepingCapacity: false) startingRunIDs.removeAll(keepingCapacity: false) startupCancellations.removeAll(keepingCapacity: false) - activeRuns.removeAll(keepingCapacity: false) + cancellationAuthorities.removeAll(keepingCapacity: false) + activeAttempts.removeAll(keepingCapacity: false) reviewRecoveryWaitingRunIDs.removeAll(keepingCapacity: false) } } extension CodexReviewStore { - package func runtimeReviewRunState(runID: String) -> CodexReviewRuntimeRunState { + package func runtimeReviewRunState(runID: ReviewRunID) -> CodexReviewRuntimeRunState { runtimeState.runState(for: runID) } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index bac56de4..5bf555a5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift @@ -1,18 +1,6 @@ import Foundation extension CodexReviewStore { - nonisolated(unsafe) private static var requestCancellationDelayForTestingStorage: TimeInterval = 0 - nonisolated(unsafe) package static var requestCancellationDelay: TimeInterval { - get { requestCancellationDelayForTestingStorage } - set { requestCancellationDelayForTestingStorage = max(0, newValue) } - } - - @_spi(Testing) - public static var requestCancellationDelayForTesting: TimeInterval { - get { requestCancellationDelay } - set { requestCancellationDelay = newValue } - } - package func loadForTesting( serverState: CodexReviewServerState, authPhase: CodexReviewAuthModel.Phase = .signedOut, diff --git a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift index b5f89460..09090f10 100644 --- a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift @@ -1,16 +1,32 @@ import Foundation +@MainActor +package protocol CodexReviewPreviewRuntimeLifetime: AnyObject { + func signalCancellation() + func stop() async + func waitUntilStopped() async +} + @MainActor package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { package let seed: CodexReviewStoreSeed package var isActive = false package var currentSettingsSnapshot: CodexReviewSettings.Snapshot + private let runtimeLifetime: (any CodexReviewPreviewRuntimeLifetime)? - package init(seed: CodexReviewStoreSeed = .init()) { + package init( + seed: CodexReviewStoreSeed = .init(), + runtimeLifetime: (any CodexReviewPreviewRuntimeLifetime)? = nil + ) { self.seed = seed + self.runtimeLifetime = runtimeLifetime currentSettingsSnapshot = seed.initialSettingsSnapshot } + isolated deinit { + runtimeLifetime?.signalCancellation() + } + package var initialSettingsSnapshot: CodexReviewSettings.Snapshot { currentSettingsSnapshot } @@ -22,11 +38,14 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { store.transitionToFailed(Self.previewUnavailableMessage) } - package func stop(store _: CodexReviewStore) async { + package func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { + await runtimeLifetime?.stop() isActive = false } - package func waitUntilStopped() async {} + package func waitUntilStopped() async { + await runtimeLifetime?.waitUntilStopped() + } package func refreshSettings() async throws -> CodexReviewSettings.Snapshot { currentSettingsSnapshot @@ -66,19 +85,15 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { } } - package func signIn(auth: CodexReviewAuthModel) async { - auth.updatePhase(.failed(message: Self.previewAuthenticationFailureMessage)) + package func signIn(auth: CodexReviewAuthModel) async throws { + auth.updatePhase(.failed(.runtime(message: Self.previewAuthenticationFailureMessage))) } - package func addAccount(auth: CodexReviewAuthModel) async { - auth.updatePhase(.failed(message: Self.previewAuthenticationFailureMessage)) + package func addAccount(auth: CodexReviewAuthModel) async throws { + auth.updatePhase(.failed(.runtime(message: Self.previewAuthenticationFailureMessage))) } - package func cancelAuthentication(auth: CodexReviewAuthModel) async { - if auth.selectedAccount == nil { - auth.updatePhase(.signedOut) - } - } + package func cancelAuthentication(auth _: CodexReviewAuthModel) async {} package func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { @@ -137,9 +152,9 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { throw CodexReviewAPI.Error.io(Self.previewUnavailableMessage) } - package func interruptReview(_: CodexReviewBackendModel.Review.Run, reason _: CodexReviewBackendModel.CancellationReason) async throws {} + package func interruptReview(_: ReviewAttempt, reason _: CodexReviewBackendModel.CancellationReason) async throws {} - package func prepareReviewRestart(_: CodexReviewBackendModel.Review.Run) async throws -> CodexReviewBackendModel.Review.RestartToken { + package func prepareReviewRestart(_: ReviewAttempt) async throws -> CodexReviewBackendModel.Review.RestartToken { throw CodexReviewAPI.Error.io(Self.previewUnavailableMessage) } @@ -150,7 +165,20 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { throw CodexReviewAPI.Error.io(Self.previewUnavailableMessage) } - package func cleanupReview(_: CodexReviewBackendModel.Review.Run) async {} + package func discardPreparedReviewRestart( + _: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + preconditionFailure("Preview backend cannot own a prepared review restart token.") + } + + package func cleanupReview(_: ReviewAttempt) async {} + + package func cleanupRetainedReviews( + _: [ReviewAttempt], + additionalThreadIDs _: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + .init() + } fileprivate static let previewUnavailableMessage = "Embedded server is unavailable in preview mode." fileprivate static let previewAuthenticationFailureMessage = "Authentication is unavailable in preview mode." diff --git a/Sources/CodexReviewKit/Store/ReviewStoreCommitSink.swift b/Sources/CodexReviewKit/Store/ReviewStoreCommitSink.swift new file mode 100644 index 00000000..3bfe523e --- /dev/null +++ b/Sources/CodexReviewKit/Store/ReviewStoreCommitSink.swift @@ -0,0 +1,282 @@ +import Foundation + +enum ReviewStoreStartupDisposition: Sendable { + case proceed + case cleanupOnly(ReviewCancellation) + case abandoned +} + +struct ReviewStoreWorkerSnapshot: Sendable { + let isTerminal: Bool + let pendingCancellation: ReviewCancellation? + let cancellation: ReviewCancellation? +} + +@MainActor +final class ReviewStoreCommitSink { + private weak var store: CodexReviewStore? + + init(store: CodexReviewStore) { + self.store = store + } + + func startupDisposition(for runID: ReviewRunID) -> ReviewStoreStartupDisposition { + guard let store, let runRecord = store.reviewRun(id: runID) else { + return .abandoned + } + if let cancellation = store.runtimeState.startupCancellation(for: runID) + ?? runRecord.pendingCancellation + ?? runRecord.core.cancellation + { + return .cleanupOnly(cancellation) + } + return runRecord.isTerminal ? .abandoned : .proceed + } + + func publishAttempt( + _ attempt: ReviewAttempt, + runID: ReviewRunID, + attemptGeneration: UInt64 + ) -> Bool { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false, + store.runtimeState.startupCancellation(for: runID) == nil + else { + return false + } + let startedAt: Date + switch runRecord.core { + case .queued: + startedAt = store.clock.now() + case .running(_, let existingStartedAt): + startedAt = existingStartedAt + case .startFailed, .cancelledBeforeStart, .succeeded, .failed, .cancelled: + return false + } + store.runtimeState.clearStarting(runID) + _ = store.runtimeState.takeStartupCancellation(for: runID) + store.runtimeState.setActiveAttempt(attempt, for: runID) + store.runtimeState.setCancellationAuthority(.interrupt(attempt), for: runID) + runRecord.core = .running(attempt: attempt, startedAt: startedAt) + runRecord.executionPhase = .running(attemptGeneration: attemptGeneration) + store.noteReviewRunMutation() + return true + } + + func workerSnapshot(for runID: ReviewRunID) -> ReviewStoreWorkerSnapshot? { + guard let store, let runRecord = store.reviewRun(id: runID) else { + return nil + } + return .init( + isTerminal: runRecord.isTerminal, + pendingCancellation: runRecord.pendingCancellation, + cancellation: runRecord.pendingCancellation ?? runRecord.core.cancellation + ) + } + + func setExecutionPhase( + _ phase: ReviewExecutionPhase, + cancellationAuthority: ReviewWorkerCancellationAuthority, + for runID: ReviewRunID + ) { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false else { + return + } + runRecord.executionPhase = phase + store.runtimeState.setCancellationAuthority(cancellationAuthority, for: runID) + if case .waitingForNetwork = phase { + store.runtimeState.markWaitingForNetworkRecovery(runID) + } else { + store.runtimeState.clearWaitingForNetworkRecovery(runID) + } + store.noteReviewRunMutation() + } + + func setCancellationAuthority( + _ authority: ReviewWorkerCancellationAuthority, + for runID: ReviewRunID + ) { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false else { + return + } + store.runtimeState.setCancellationAuthority(authority, for: runID) + } + + func commitTerminal(_ terminal: ReviewBackendTerminal, for runID: ReviewRunID) { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false else { + return + } + if let cancellation = runRecord.pendingCancellation { + store.commitCancellationLocally(runRecord, cancellation: cancellation) + return + } + switch terminal { + case .completed: + guard case .running(let attempt, let startedAt) = runRecord.core else { + preconditionFailure("Only a running review attempt can complete.") + } + runRecord.core = .succeeded( + attempt: attempt, + startedAt: startedAt, + endedAt: store.clock.now() + ) + runRecord.pendingCancellation = nil + runRecord.cancellationRequested = false + runRecord.executionPhase = nil + store.noteReviewRunMutation() + case .interrupted(let message): + markFailure( + .interruptedByBackend(message: message), + runRecord: runRecord, + store: store + ) + case .failed(let failure): + markFailure(failure, runRecord: runRecord, store: store) + } + } + + func commitWorkerCancellation( + for runID: ReviewRunID, + fallback: ReviewCancellation = .system() + ) { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false else { + return + } + store.commitCancellationLocally( + runRecord, + cancellation: runRecord.pendingCancellation + ?? store.runtimeState.startupCancellation(for: runID) + ?? fallback + ) + } + + func markWorkerFailure(_ failure: ReviewBackendFailure, for runID: ReviewRunID) { + guard let store, + let runRecord = store.reviewRun(id: runID), + runRecord.isTerminal == false else { + return + } + if let cancellation = runRecord.pendingCancellation + ?? store.runtimeState.startupCancellation(for: runID) + { + store.commitCancellationLocally(runRecord, cancellation: cancellation) + return + } + markFailure(failure, runRecord: runRecord, store: store) + } + + func interruptSucceeded( + runID: ReviewRunID, + cancellation: ReviewCancellation + ) { + guard let store, let runRecord = store.reviewRun(id: runID) else { + return + } + if runRecord.isTerminal == false { + store.commitCancellationLocally( + runRecord, + cancellation: runRecord.pendingCancellation ?? cancellation + ) + } + store.runtimeState.cancelActiveWorker(for: runID) + } + + func cancelWorker(for runID: ReviewRunID) { + store?.runtimeState.cancelActiveWorker(for: runID) + } + + func cancelReview( + runID: ReviewRunID, + cancellation: ReviewCancellation + ) async -> ReviewBackendFailure? { + guard let store else { + return .protocolViolation( + message: "The review store was released before its accepted cancellation completed." + ) + } + do { + _ = try await store.cancelReview(runID: runID, cancellation: cancellation) + return nil + } catch { + return cancellationBackendFailure(error) + } + } + + func interruptFailed( + runID: ReviewRunID, + resumePhase: ReviewExecutionPhase + ) -> Bool { + guard let store, let runRecord = store.reviewRun(id: runID) else { + return true + } + guard runRecord.isTerminal == false else { + return true + } + runRecord.cancellationRequested = false + runRecord.pendingCancellation = nil + runRecord.executionPhase = resumePhase + store.noteReviewRunMutation() + return false + } + + func cancellationOperationFinished( + runID: ReviewRunID, + token: ReviewCancellationOperationToken + ) { + store?.runtimeState.cancellationOperationFinished(for: runID, token: token) + } + + func workerFinished(runID: ReviewRunID, generation: ReviewWorkerGeneration) { + guard let store else { + return + } + store.runtimeState.clearReviewRunState(for: runID) + store.runtimeState.workerFinished(for: runID, generation: generation) + } + + func cancellationOutcome(runID: ReviewRunID) -> CodexReviewAPI.Cancel.Outcome? { + guard let store, let runRecord = store.reviewRun(id: runID) else { + return nil + } + return .init( + runID: runID, + cancelled: runRecord.core.status == .cancelled, + core: runRecord.core, + presentation: runRecord.presentation + ) + } + + private func markFailure( + _ failure: ReviewBackendFailure, + runRecord: ReviewRunRecord, + store: CodexReviewStore + ) { + let endedAt = store.clock.now() + switch runRecord.core { + case .queued: + runRecord.core = .startFailed(endedAt: endedAt, failure: failure) + case .running(let attempt, let startedAt): + runRecord.core = .failed( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + failure: failure + ) + case .startFailed, .cancelledBeforeStart, .succeeded, .failed, .cancelled: + return + } + runRecord.cancellationRequested = false + runRecord.pendingCancellation = nil + runRecord.executionPhase = nil + store.noteReviewRunMutation() + } +} diff --git a/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift b/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift new file mode 100644 index 00000000..7b9238c6 --- /dev/null +++ b/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift @@ -0,0 +1,1208 @@ +import Foundation + +package struct ReviewWorkerState: Sendable { + package struct Outage: Sendable { + package let epoch: UInt64 + package let observedAt: ReviewWorkerClock.Instant + package let presentationDate: Date + } + + package enum LiveNetworkPhase: Sendable { + case satisfied + case pendingOutage( + Outage, + heldConnectionTermination: ReviewBackendConnectionTermination? + ) + } + + package enum WaitingConnectivity: Sendable { + case unsatisfied(nextSettleGeneration: UInt64) + case settling(generation: UInt64) + } + + package enum Stage: Sendable { + case live(attempt: BackendReviewAttempt, network: LiveNetworkPhase) + case preparingRestart(interruptedAttempt: ReviewAttempt, outage: Outage) + case waitingForNetwork( + interruptedAttempt: ReviewAttempt, + outage: Outage, + token: CodexReviewBackendModel.Review.RestartToken, + connectivity: WaitingConnectivity + ) + case restarting( + interruptedAttempt: ReviewAttempt, + outage: Outage, + token: CodexReviewBackendModel.Review.RestartToken + ) + } + + package var attemptGeneration: UInt64 + package var nextOutageEpoch: UInt64 + package var stage: Stage +} + +package struct ReviewWorkerConnectivitySnapshot: Sendable { + package enum Connectivity: Sendable { + case satisfied + case outage + } + + package let connectivity: Connectivity + package let observedAt: ReviewWorkerClock.Instant + package let presentationDate: Date + + init( + _ snapshot: CodexReviewNetworkSnapshot, + clock: ReviewWorkerClock + ) { + switch snapshot.status { + case .satisfied: + connectivity = .satisfied + case .unsatisfied, .requiresConnection: + connectivity = .outage + } + observedAt = clock.now + presentationDate = snapshot.observedAt + } +} + +package enum ReviewWorkerSignal: Sendable { + case backendTerminal(generation: UInt64, ReviewBackendTerminal) + case resultWaitCancelled(generation: UInt64) + case networkSnapshot(generation: UInt64, ReviewWorkerConnectivitySnapshot) + case networkSourceFinished(generation: UInt64) + case outageDebounceElapsed(generation: UInt64, outageEpoch: UInt64) + case outageDebounceCancelled(generation: UInt64, outageEpoch: UInt64) + case recoverySettleElapsed( + generation: UInt64, + outageEpoch: UInt64, + settleGeneration: UInt64 + ) + case recoverySettleCancelled( + generation: UInt64, + outageEpoch: UInt64, + settleGeneration: UInt64 + ) + case prepareRestartCompleted( + generation: UInt64, + outageEpoch: UInt64, + Result + ) + case prepareRestartCancelled(generation: UInt64, outageEpoch: UInt64) + case restartCompleted( + generation: UInt64, + outageEpoch: UInt64, + Result + ) + case restartCancelled(generation: UInt64, outageEpoch: UInt64) + + var generation: UInt64 { + switch self { + case .backendTerminal(let generation, _), + .resultWaitCancelled(let generation), + .networkSnapshot(let generation, _), + .networkSourceFinished(let generation), + .outageDebounceElapsed(let generation, _), + .outageDebounceCancelled(let generation, _), + .recoverySettleElapsed(let generation, _, _), + .recoverySettleCancelled(let generation, _, _), + .prepareRestartCompleted(let generation, _, _), + .prepareRestartCancelled(let generation, _), + .restartCompleted(let generation, _, _), + .restartCancelled(let generation, _): + generation + } + } +} + +@MainActor +struct ReviewStoreWorker { + let runID: ReviewRunID + let startRequest: CodexReviewBackendModel.Review.Start + let backend: any CodexReviewStoreBackend + let networkMonitor: any CodexReviewNetworkMonitoring + let networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy + let retentionRegistry: ReviewThreadRetentionRegistry + let retentionScope: ReviewThreadRetentionScope + let sink: ReviewStoreCommitSink + let workerGeneration: ReviewWorkerGeneration + + func run() async { + defer { + sink.workerFinished(runID: runID, generation: workerGeneration) + } + + let initialAttempt: BackendReviewAttempt + do { + initialAttempt = try await backend.startReview(startRequest) + } catch is CancellationError { + sink.commitWorkerCancellation(for: runID) + return + } catch { + sink.markWorkerFailure( + backendFailure(error, operation: "startReview"), + for: runID + ) + return + } + + switch sink.startupDisposition(for: runID) { + case .cleanupOnly(let cancellation): + await rollbackUnpublishedAttempt( + initialAttempt.attempt, + cancellation: cancellation, + claimOwnership: true + ) + return + case .abandoned: + await rollbackUnpublishedAttempt( + initialAttempt.attempt, + cancellation: .system(), + claimOwnership: true + ) + return + case .proceed: + break + } + + do { + try await claimRetention(initialAttempt.attempt) + } catch let failure as ReviewBackendFailure { + sink.markWorkerFailure(failure, for: runID) + return + } catch { + sink.markWorkerFailure( + .protocolViolation( + message: "Review retention ownership failed with an unsupported error: \(error.localizedDescription)" + ), + for: runID + ) + return + } + + switch sink.startupDisposition(for: runID) { + case .cleanupOnly(let cancellation): + await rollbackUnpublishedAttempt( + initialAttempt.attempt, + cancellation: cancellation, + claimOwnership: false + ) + return + case .abandoned: + await rollbackUnpublishedAttempt( + initialAttempt.attempt, + cancellation: .system(), + claimOwnership: false + ) + return + case .proceed: + break + } + + guard sink.publishAttempt( + initialAttempt.attempt, + runID: runID, + attemptGeneration: 0 + ) else { + await rollbackUnpublishedAttempt( + initialAttempt.attempt, + cancellation: .system(), + claimOwnership: false + ) + return + } + + var cleanedAttempts: Set = [] + var state = ReviewWorkerState( + attemptGeneration: 0, + nextOutageEpoch: 1, + stage: .live(attempt: initialAttempt, network: .satisfied) + ) + + while true { + switch state.stage { + case .live(let attempt, _): + switch await runLivePhase(state: &state) { + case .continueWithUpdatedState: + continue + case .terminal(let terminal): + sink.commitTerminal(terminal, for: runID) + await cleanupOnce(attempt.attempt, cleanedAttempts: &cleanedAttempts) + return + case .confirmedOutage(let outage): + state.stage = .preparingRestart( + interruptedAttempt: attempt.attempt, + outage: outage + ) + sink.setExecutionPhase( + .preparingRestart, + cancellationAuthority: .workerCleanup, + for: runID + ) + case .cancelled: + await finishParentCancellation( + activeAttempt: attempt.attempt, + preparedToken: nil, + cleanedAttempts: &cleanedAttempts + ) + return + case .failed(let failure): + sink.markWorkerFailure(failure, for: runID) + await cleanupOnce(attempt.attempt, cleanedAttempts: &cleanedAttempts) + return + } + + case .preparingRestart(let interruptedAttempt, let outage): + let decision = await prepareRestart( + interruptedAttempt, + generation: state.attemptGeneration, + outage: outage + ) + switch decision { + case .success(let token): + await cleanupOnce(interruptedAttempt, cleanedAttempts: &cleanedAttempts) + if Task.isCancelled || sink.workerSnapshot(for: runID)?.isTerminal == true + || sink.workerSnapshot(for: runID)?.pendingCancellation != nil + { + await finishParentCancellation( + activeAttempt: nil, + preparedToken: token, + cleanedAttempts: &cleanedAttempts + ) + return + } + state.stage = .waitingForNetwork( + interruptedAttempt: interruptedAttempt, + outage: outage, + token: token, + connectivity: .unsatisfied(nextSettleGeneration: 1) + ) + sink.setExecutionPhase( + .waitingForNetwork(since: outage.presentationDate), + cancellationAuthority: .workerCleanup, + for: runID + ) + case .failure(let failure): + await cleanupOnce(interruptedAttempt, cleanedAttempts: &cleanedAttempts) + sink.markWorkerFailure(failure, for: runID) + return + case .cancelled: + await cleanupOnce(interruptedAttempt, cleanedAttempts: &cleanedAttempts) + if Task.isCancelled { + sink.commitWorkerCancellation(for: runID) + } else { + sink.markWorkerFailure(.prepareRestartCancelledUnexpectedly, for: runID) + } + return + } + + case .waitingForNetwork( + let interruptedAttempt, + let outage, + let token, + _ + ): + switch await waitForNetwork(state: &state) { + case .continueWithUpdatedState: + continue + case .readyToRestart: + state.stage = .restarting( + interruptedAttempt: interruptedAttempt, + outage: outage, + token: token + ) + sink.setExecutionPhase( + .restarting, + cancellationAuthority: .workerCleanup, + for: runID + ) + case .cancelled: + await finishParentCancellation( + activeAttempt: nil, + preparedToken: token, + cleanedAttempts: &cleanedAttempts + ) + return + case .failed(let failure): + await discardPreparedToken(token, cleanedAttempts: &cleanedAttempts) + sink.markWorkerFailure(failure, for: runID) + return + } + + case .restarting(_, let outage, let token): + switch await restartReview( + token, + generation: state.attemptGeneration, + outage: outage + ) { + case .success(let replacement): + do { + try await claimRetention(replacement.attempt) + } catch let failure as ReviewBackendFailure { + sink.markWorkerFailure(failure, for: runID) + return + } catch { + sink.markWorkerFailure( + .protocolViolation( + message: "Replacement review retention failed with an unsupported error: \(error.localizedDescription)" + ), + for: runID + ) + return + } + if Task.isCancelled || sink.workerSnapshot(for: runID)?.isTerminal == true + || sink.workerSnapshot(for: runID)?.pendingCancellation != nil + { + await interruptAndCleanup( + replacement.attempt, + cancellation: sink.workerSnapshot(for: runID)?.cancellation ?? .system() + ) + sink.commitWorkerCancellation(for: runID) + return + } + state.attemptGeneration += 1 + state.nextOutageEpoch = 1 + guard sink.publishAttempt( + replacement.attempt, + runID: runID, + attemptGeneration: state.attemptGeneration + ) else { + await interruptAndCleanup(replacement.attempt, cancellation: .system()) + return + } + state.stage = .live(attempt: replacement, network: .satisfied) + case .failure(let failure): + await discardPreparedToken(token, cleanedAttempts: &cleanedAttempts) + sink.markWorkerFailure(failure, for: runID) + return + case .cancelled: + await discardPreparedToken(token, cleanedAttempts: &cleanedAttempts) + if Task.isCancelled { + sink.commitWorkerCancellation(for: runID) + } else { + sink.markWorkerFailure(.restartCancelledUnexpectedly, for: runID) + } + return + } + } + } + } + + private func runLivePhase( + state: inout ReviewWorkerState + ) async -> ReviewLivePhaseDecision { + guard case .live(let backendAttempt, let initialNetworkPhase) = state.stage else { + preconditionFailure("A live phase requires a live worker stage.") + } + let generation = state.attemptGeneration + let policy = networkRecoveryPolicy + let networkSource = ReviewWorkerNetworkSource(monitor: networkMonitor) + return await withTaskGroup(of: ReviewWorkerSignal.self) { group in + group.addTask { + await observeTerminal(backendAttempt, generation: generation) + } + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) + var networkPhase = initialNetworkPhase + if case .pendingOutage(let outage, _) = networkPhase { + addOutageTimer( + to: &group, + policy: policy, + generation: generation, + outageEpoch: outage.epoch + ) + } + + while let signal = await group.next() { + guard signal.generation == generation else { + if signal.generation < generation { + continue + } + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + return .failed(drain.terminalFailure ?? .protocolViolation( + message: "A future review generation emitted into the current live phase." + )) + } + + if Task.isCancelled { + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.terminal { + return .terminal(terminal) + } + return .cancelled + } + + switch signal { + case .backendTerminal(_, let terminal) where terminal.isConnectionTermination: + switch networkPhase { + case .satisfied: + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + return .terminal(drain.terminal ?? terminal) + case .pendingOutage(let outage, let held): + guard held == nil, + case .failed(.connectionTerminated(let termination)) = terminal else { + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + return .failed(drain.terminalFailure ?? .protocolViolation( + message: "A pending outage produced duplicate or malformed connection terminals." + )) + } + networkPhase = .pendingOutage( + outage, + heldConnectionTermination: termination + ) + state.stage = .live(attempt: backendAttempt, network: networkPhase) + sink.setCancellationAuthority(.workerCleanup, for: runID) + } + + case .backendTerminal: + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + guard let terminal = drain.terminal else { + return .failed(.protocolViolation( + message: "A backend terminal signal was lost while draining the live phase." + )) + } + return .terminal(terminal) + + case .resultWaitCancelled: + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.terminal { + return .terminal(terminal) + } + return .failed(.protocolViolation( + message: "Review terminal observation was cancelled without parent cancellation." + )) + + case .networkSnapshot(_, let snapshot): + switch (networkPhase, snapshot.connectivity) { + case (.satisfied, .satisfied): + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) + case (.satisfied, .outage): + let outage = ReviewWorkerState.Outage( + epoch: state.nextOutageEpoch, + observedAt: snapshot.observedAt, + presentationDate: snapshot.presentationDate + ) + state.nextOutageEpoch += 1 + networkPhase = .pendingOutage(outage, heldConnectionTermination: nil) + state.stage = .live(attempt: backendAttempt, network: networkPhase) + addOutageTimer( + to: &group, + policy: policy, + generation: generation, + outageEpoch: outage.epoch + ) + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) + case (.pendingOutage, .outage): + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) + case (.pendingOutage(_, let held), .satisfied): + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.nonConnectionTerminal { + return .terminal(terminal) + } + if let held { + return .terminal(.failed(.connectionTerminated(held))) + } + state.stage = .live(attempt: backendAttempt, network: .satisfied) + sink.setCancellationAuthority(.interrupt(backendAttempt.attempt), for: runID) + return .continueWithUpdatedState + } + + case .networkSourceFinished: + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.nonConnectionTerminal { + return .terminal(terminal) + } + if case .pendingOutage(_, let held) = networkPhase, let held { + return .terminal(.failed(.connectionTerminated(held))) + } + return .failed(.connectivityObservationEnded) + + case .outageDebounceElapsed(_, let outageEpoch): + guard case .pendingOutage(let outage, _) = networkPhase else { + if outageEpoch < state.nextOutageEpoch { + continue + } + return .failed(.protocolViolation( + message: "A future outage timer fired outside a pending outage." + )) + } + guard outageEpoch == outage.epoch else { + if outageEpoch < outage.epoch { + continue + } + return .failed(.protocolViolation( + message: "A future outage timer fired for the current attempt." + )) + } + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.nonConnectionTerminal { + return .terminal(terminal) + } + if Task.isCancelled { + return .cancelled + } + return .confirmedOutage(outage) + + case .outageDebounceCancelled(_, let outageEpoch): + guard case .pendingOutage(let outage, _) = networkPhase, + outageEpoch == outage.epoch else { + if outageEpoch < state.nextOutageEpoch { + continue + } + return .failed(.protocolViolation( + message: "An outage timer was cancelled for an impossible epoch." + )) + } + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + if let terminal = drain.terminal { + return .terminal(terminal) + } + return Task.isCancelled + ? .cancelled + : .failed(.protocolViolation( + message: "Network outage debounce was cancelled unexpectedly." + )) + + case .recoverySettleElapsed, .recoverySettleCancelled, + .prepareRestartCompleted, .prepareRestartCancelled, + .restartCompleted, .restartCancelled: + let drain = await drainLiveGroup(&group, startingWith: signal, generation: generation) + return .failed(drain.terminalFailure ?? .protocolViolation( + message: "A non-live signal was emitted into the live review phase." + )) + } + } + + return Task.isCancelled + ? .cancelled + : .failed(.protocolViolation(message: "The live review phase ended without a decision.")) + } + } + + private func prepareRestart( + _ attempt: ReviewAttempt, + generation: UInt64, + outage: ReviewWorkerState.Outage + ) async -> ReviewPhaseOperationResult { + let operation: @MainActor @Sendable () async throws + -> CodexReviewBackendModel.Review.RestartToken = { [backend] in + try await backend.prepareReviewRestart(attempt) + } + return await withTaskGroup(of: ReviewWorkerSignal.self) { group in + group.addTask { + do { + return .prepareRestartCompleted( + generation: generation, + outageEpoch: outage.epoch, + .success(try await operation()) + ) + } catch is CancellationError { + return .prepareRestartCancelled( + generation: generation, + outageEpoch: outage.epoch + ) + } catch { + return .prepareRestartCompleted( + generation: generation, + outageEpoch: outage.epoch, + .failure(backendFailure(error, operation: "prepareReviewRestart")) + ) + } + } + guard let signal = await group.next() else { + return .failure(.protocolViolation( + message: "Review restart preparation ended without a signal." + )) + } + group.cancelAll() + while await group.next() != nil {} + guard signal.generation == generation else { + return .failure(.protocolViolation( + message: "Review restart preparation returned a mismatched generation." + )) + } + switch signal { + case .prepareRestartCompleted(_, let epoch, let result) where epoch == outage.epoch: + switch result { + case .success(let token): + return .success(token) + case .failure(let failure): + return .failure(failure) + } + case .prepareRestartCancelled(_, let epoch) where epoch == outage.epoch: + return .cancelled + default: + return .failure(.protocolViolation( + message: "Review restart preparation returned an impossible signal." + )) + } + } + } + + private func waitForNetwork( + state: inout ReviewWorkerState + ) async -> ReviewNetworkPhaseDecision { + guard case .waitingForNetwork( + let interruptedAttempt, + let outage, + let token, + let initialConnectivity + ) = state.stage else { + preconditionFailure("A network wait requires a waiting worker stage.") + } + let generation = state.attemptGeneration + let policy = networkRecoveryPolicy + let source = ReviewWorkerNetworkSource(monitor: networkMonitor) + return await withTaskGroup(of: ReviewWorkerSignal.self) { group in + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) + var connectivity = initialConnectivity + if case .settling(let settleGeneration) = connectivity { + addSettleTimer( + to: &group, + policy: policy, + generation: generation, + outageEpoch: outage.epoch, + settleGeneration: settleGeneration + ) + } + while let signal = await group.next() { + if Task.isCancelled { + group.cancelAll() + while await group.next() != nil {} + return .cancelled + } + guard signal.generation == generation else { + if signal.generation < generation { + continue + } + group.cancelAll() + while await group.next() != nil {} + return .failed(.protocolViolation( + message: "A future review generation emitted during network recovery." + )) + } + switch signal { + case .networkSnapshot(_, let snapshot): + switch (connectivity, snapshot.connectivity) { + case (.unsatisfied, .outage): + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) + case (.unsatisfied(let next), .satisfied): + connectivity = .settling(generation: next) + state.stage = .waitingForNetwork( + interruptedAttempt: interruptedAttempt, + outage: outage, + token: token, + connectivity: connectivity + ) + addSettleTimer( + to: &group, + policy: policy, + generation: generation, + outageEpoch: outage.epoch, + settleGeneration: next + ) + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) + case (.settling, .satisfied): + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) + case (.settling(let current), .outage): + group.cancelAll() + while await group.next() != nil {} + state.stage = .waitingForNetwork( + interruptedAttempt: interruptedAttempt, + outage: outage, + token: token, + connectivity: .unsatisfied(nextSettleGeneration: current + 1) + ) + return .continueWithUpdatedState + } + case .networkSourceFinished: + group.cancelAll() + while await group.next() != nil {} + return .failed(.connectivityObservationEnded) + case .recoverySettleElapsed(_, let epoch, let settleGeneration): + guard epoch == outage.epoch, + case .settling(let current) = connectivity else { + if epoch < outage.epoch { + continue + } + return .failed(.protocolViolation( + message: "A network settle timer fired outside a settling window." + )) + } + guard settleGeneration == current else { + if settleGeneration < current { + continue + } + return .failed(.protocolViolation( + message: "A future network settle timer fired." + )) + } + group.cancelAll() + while await group.next() != nil {} + return Task.isCancelled ? .cancelled : .readyToRestart + case .recoverySettleCancelled(_, let epoch, let settleGeneration): + if epoch < outage.epoch { + continue + } + if case .settling(let current) = connectivity, settleGeneration < current { + continue + } + group.cancelAll() + while await group.next() != nil {} + return Task.isCancelled + ? .cancelled + : .failed(.protocolViolation( + message: "Network recovery settling was cancelled unexpectedly." + )) + case .backendTerminal, .resultWaitCancelled, + .outageDebounceElapsed, .outageDebounceCancelled, + .prepareRestartCompleted, .prepareRestartCancelled, + .restartCompleted, .restartCancelled: + group.cancelAll() + while await group.next() != nil {} + return .failed(.protocolViolation( + message: "A non-network signal was emitted during network recovery." + )) + } + } + return Task.isCancelled + ? .cancelled + : .failed(.protocolViolation(message: "Network recovery ended without a decision.")) + } + } + + private func restartReview( + _ token: CodexReviewBackendModel.Review.RestartToken, + generation: UInt64, + outage: ReviewWorkerState.Outage + ) async -> ReviewPhaseOperationResult { + let operation: @MainActor @Sendable () async throws -> BackendReviewAttempt = { + [backend, startRequest] in + try await backend.restartPreparedReview(token, request: startRequest) + } + return await withTaskGroup(of: ReviewWorkerSignal.self) { group in + group.addTask { + do { + return .restartCompleted( + generation: generation, + outageEpoch: outage.epoch, + .success(try await operation()) + ) + } catch is CancellationError { + return .restartCancelled( + generation: generation, + outageEpoch: outage.epoch + ) + } catch { + return .restartCompleted( + generation: generation, + outageEpoch: outage.epoch, + .failure(backendFailure(error, operation: "restartPreparedReview")) + ) + } + } + guard let signal = await group.next() else { + return .failure(.protocolViolation(message: "Review restart ended without a signal.")) + } + group.cancelAll() + while await group.next() != nil {} + guard signal.generation == generation else { + return .failure(.protocolViolation( + message: "Review restart returned a mismatched generation." + )) + } + switch signal { + case .restartCompleted(_, let epoch, let result) where epoch == outage.epoch: + switch result { + case .success(let attempt): + return .success(attempt) + case .failure(let failure): + return .failure(failure) + } + case .restartCancelled(_, let epoch) where epoch == outage.epoch: + return .cancelled + default: + return .failure(.protocolViolation( + message: "Review restart returned an impossible signal." + )) + } + } + } + + private func finishParentCancellation( + activeAttempt: ReviewAttempt?, + preparedToken: CodexReviewBackendModel.Review.RestartToken?, + cleanedAttempts: inout Set + ) async { + if let preparedToken { + await discardPreparedToken(preparedToken, cleanedAttempts: &cleanedAttempts) + } + let snapshot = sink.workerSnapshot(for: runID) + if snapshot?.isTerminal == false, snapshot?.pendingCancellation == nil, + let activeAttempt + { + _ = await interruptForCleanup(activeAttempt, cancellation: .system()) + } + if let activeAttempt { + await cleanupOnce(activeAttempt, cleanedAttempts: &cleanedAttempts) + } + sink.commitWorkerCancellation(for: runID) + } + + private func discardPreparedToken( + _ token: CodexReviewBackendModel.Review.RestartToken, + cleanedAttempts: inout Set + ) async { + let lateAttempts = await backend.discardPreparedReviewRestart(token) + for attempt in lateAttempts { + do { + try await claimRetention(attempt) + } catch { + // claimRetention owns rollback cleanup before it throws. + continue + } + guard cleanedAttempts.contains(attempt.attemptID) == false else { + continue + } + _ = await interruptForCleanup( + attempt, + cancellation: sink.workerSnapshot(for: runID)?.cancellation ?? .system() + ) + await cleanupOnce(attempt, cleanedAttempts: &cleanedAttempts) + } + } + + private func claimRetention(_ attempt: ReviewAttempt) async throws { + do { + try await retentionRegistry.claim( + attempt, + for: runID, + scope: retentionScope + ) + } catch { + let journalFailure = (error as? ReviewThreadRetentionRegistryError)?.message + ?? error.localizedDescription + _ = await interruptForCleanup( + attempt, + cancellation: .system(message: "Review retention journal commit failed.") + ) + await backend.cleanupReview(attempt) + let pending = await retentionRegistry.pendingEntry(for: runID) + let cleanup = await backend.cleanupRetainedReviews( + pending?.attempts ?? [attempt], + additionalThreadIDs: pending?.additionalCleanupThreadIDs ?? [] + ) + await retentionRegistry.recordFailedClaimCleanup( + runID: runID, + journalFailure: journalFailure, + failedThreadIDs: cleanup.failures.map(\.threadID), + cleanupFailure: cleanup.failureMessage + ) + throw ReviewBackendFailure.retentionJournal( + message: "Review identity could not be committed to the retention journal: \(journalFailure)" + ) + } + } + + private func rollbackUnpublishedAttempt( + _ attempt: ReviewAttempt, + cancellation: ReviewCancellation, + claimOwnership: Bool + ) async { + if claimOwnership { + do { + try await claimRetention(attempt) + } catch { + // claimRetention owns the quarantine rollback when ownership + // cannot be committed. + return + } + } + + _ = await interruptForCleanup(attempt, cancellation: cancellation) + await backend.cleanupReview(attempt) + let cleanup = await backend.cleanupRetainedReviews( + [attempt], + additionalThreadIDs: [] + ) + if cleanup.succeeded { + await retentionRegistry.recordCleanupSucceeded(for: runID) + } else { + _ = await retentionRegistry.recordCleanupFailed( + for: runID, + failedThreadIDs: cleanup.failures.map(\.threadID), + message: cleanup.failureMessage ?? "Unpublished review thread cleanup failed." + ) + } + } + + private func interruptAndCleanup( + _ attempt: ReviewAttempt, + cancellation: ReviewCancellation + ) async { + _ = await interruptForCleanup(attempt, cancellation: cancellation) + await backend.cleanupReview(attempt) + } + + private func interruptForCleanup( + _ attempt: ReviewAttempt, + cancellation: ReviewCancellation + ) async -> ReviewBackendFailure? { + do { + try await backend.interruptReview( + attempt, + reason: .init(message: cancellation.message) + ) + return nil + } catch { + return backendFailure(error, operation: "interruptReview") + } + } + + private func cleanupOnce( + _ attempt: ReviewAttempt, + cleanedAttempts: inout Set + ) async { + guard cleanedAttempts.insert(attempt.attemptID).inserted else { + preconditionFailure("A started review attempt can only be cleaned once by its worker.") + } + await backend.cleanupReview(attempt) + } +} + +private enum ReviewLivePhaseDecision: Sendable { + case continueWithUpdatedState + case terminal(ReviewBackendTerminal) + case confirmedOutage(ReviewWorkerState.Outage) + case cancelled + case failed(ReviewBackendFailure) +} + +private enum ReviewNetworkPhaseDecision: Sendable { + case continueWithUpdatedState + case readyToRestart + case cancelled + case failed(ReviewBackendFailure) +} + +private enum ReviewPhaseOperationResult: Sendable { + case success(Value) + case failure(ReviewBackendFailure) + case cancelled +} + +private struct ReviewLivePhaseDrain: Sendable { + var terminal: ReviewBackendTerminal? + var duplicateTerminal = false + + var nonConnectionTerminal: ReviewBackendTerminal? { + guard terminal?.isConnectionTermination == false else { + return nil + } + return terminal + } + + var terminalFailure: ReviewBackendFailure? { + if duplicateTerminal { + return .protocolViolation( + message: "A review phase produced more than one backend terminal." + ) + } + if case .failed(let failure) = terminal { + return failure + } + return nil + } + + mutating func record(_ signal: ReviewWorkerSignal, generation: UInt64) { + guard signal.generation == generation else { + return + } + guard case .backendTerminal(_, let candidate) = signal else { + return + } + if terminal == nil { + terminal = candidate + } else { + duplicateTerminal = true + } + } +} + +private actor ReviewWorkerNetworkSource { + private final class IteratorState { + var iterator: AsyncStream.Iterator + + init(_ stream: AsyncStream) { + iterator = stream.makeAsyncIterator() + } + } + + private let state: IteratorState + + init(monitor: any CodexReviewNetworkMonitoring) { + state = IteratorState(monitor.snapshots()) + } + + func next() async -> CodexReviewNetworkSnapshot? { + var iterator = state.iterator + let next = await iterator.next(isolation: self) + state.iterator = iterator + return next + } +} + +private func observeTerminal( + _ backendAttempt: BackendReviewAttempt, + generation: UInt64 +) async -> ReviewWorkerSignal { + do { + let observed = try await backendAttempt.observeTerminal() + return .backendTerminal( + generation: generation, + await backendAttempt.finalizeTerminal(observed) + ) + } catch is CancellationError { + guard let observed = await backendAttempt.observedTerminalIfKnown() else { + return .resultWaitCancelled(generation: generation) + } + return .backendTerminal( + generation: generation, + await backendAttempt.finalizeTerminal(observed) + ) + } catch { + return .backendTerminal( + generation: generation, + .failed(.protocolViolation( + message: "Review terminal observation threw an unsupported error: \(error.localizedDescription)" + )) + ) + } +} + +private func addNetworkChild( + to group: inout TaskGroup, + source: ReviewWorkerNetworkSource, + clock: ReviewWorkerClock, + generation: UInt64 +) { + group.addTask { + guard let snapshot = await source.next() else { + return .networkSourceFinished(generation: generation) + } + return .networkSnapshot( + generation: generation, + ReviewWorkerConnectivitySnapshot(snapshot, clock: clock) + ) + } +} + +private func addOutageTimer( + to group: inout TaskGroup, + policy: CodexReviewNetworkRecoveryPolicy, + generation: UInt64, + outageEpoch: UInt64 +) { + group.addTask { + do { + try await policy.clock.sleep(for: policy.outageDebounce) + return .outageDebounceElapsed( + generation: generation, + outageEpoch: outageEpoch + ) + } catch { + return .outageDebounceCancelled( + generation: generation, + outageEpoch: outageEpoch + ) + } + } +} + +private func addSettleTimer( + to group: inout TaskGroup, + policy: CodexReviewNetworkRecoveryPolicy, + generation: UInt64, + outageEpoch: UInt64, + settleGeneration: UInt64 +) { + group.addTask { + do { + try await policy.clock.sleep(for: policy.recoverySettle) + return .recoverySettleElapsed( + generation: generation, + outageEpoch: outageEpoch, + settleGeneration: settleGeneration + ) + } catch { + return .recoverySettleCancelled( + generation: generation, + outageEpoch: outageEpoch, + settleGeneration: settleGeneration + ) + } + } +} + +@MainActor +private func drainLiveGroup( + _ group: inout TaskGroup, + startingWith signal: ReviewWorkerSignal, + generation: UInt64 +) async -> ReviewLivePhaseDrain { + var drain = ReviewLivePhaseDrain() + drain.record(signal, generation: generation) + group.cancelAll() + while let signal = await group.next() { + drain.record(signal, generation: generation) + } + return drain +} + +private func backendFailure(_ error: any Error, operation: String) -> ReviewBackendFailure { + if let failure = error as? ReviewBackendFailure { + return failure + } + return .protocolViolation( + message: "Review backend \(operation) threw an unsupported error: \(error.localizedDescription)" + ) +} diff --git a/Sources/CodexReviewKit/Store/ReviewThreadRetentionRegistry.swift b/Sources/CodexReviewKit/Store/ReviewThreadRetentionRegistry.swift new file mode 100644 index 00000000..a195ffe2 --- /dev/null +++ b/Sources/CodexReviewKit/Store/ReviewThreadRetentionRegistry.swift @@ -0,0 +1,771 @@ +import Foundation +import Darwin + +package struct ReviewThreadRetentionScope: Codable, Hashable, Sendable { + package let codexHomePath: String + package let accountKey: String? + + package init(codexHomePath: String, accountKey: String?) { + precondition( + codexHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + "A review retention scope requires a Codex home path." + ) + let normalizedPath = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .standardizedFileURL.path + self.codexHomePath = normalizedPath + self.accountKey = accountKey?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + package func matchesCodexHome(of other: Self) -> Bool { + codexHomePath == other.codexHomePath + } + + private enum CodingKeys: String, CodingKey { + case codexHomePath + case accountKey + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawPath = try container.decode(String.self, forKey: .codexHomePath) + guard rawPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw DecodingError.dataCorruptedError( + forKey: .codexHomePath, + in: container, + debugDescription: "A review retention scope requires a Codex home path." + ) + } + self.init( + codexHomePath: rawPath, + accountKey: try container.decodeIfPresent(String.self, forKey: .accountKey) + ) + } +} + +package struct ReviewThreadRetentionEntry: Codable, Hashable, Sendable { + package let runID: ReviewRunID + package let scope: ReviewThreadRetentionScope + package private(set) var attempts: [ReviewAttempt] + package private(set) var additionalCleanupThreadIDs: [ReviewThreadID] + + package init( + runID: ReviewRunID, + scope: ReviewThreadRetentionScope, + attempts: [ReviewAttempt], + additionalCleanupThreadIDs: [ReviewThreadID] = [] + ) { + let validatedAttempts: [ReviewAttempt] + do { + validatedAttempts = try Self.validatedAttempts(attempts, runID: runID) + } catch { + preconditionFailure("Invalid retained review entry: \(error)") + } + let sourceThreadID = validatedAttempts[0].threadIdentity.sourceThreadID + precondition( + validatedAttempts.allSatisfy { $0.threadIdentity.sourceThreadID == sourceThreadID }, + "All retained attempts for one run must share the source thread identity." + ) + self.runID = runID + self.scope = scope + self.attempts = validatedAttempts + self.additionalCleanupThreadIDs = Self.deduplicated(additionalCleanupThreadIDs) + } + + package var sourceThreadID: ReviewThreadID { + attempts[0].threadIdentity.sourceThreadID + } + + package mutating func merge(_ attempt: ReviewAttempt, scope: ReviewThreadRetentionScope) throws { + guard self.scope == scope else { + throw ReviewThreadRetentionRegistryError.scopeMismatch(runID: runID) + } + guard attempt.threadIdentity.sourceThreadID == sourceThreadID else { + throw ReviewThreadRetentionRegistryError.sourceThreadMismatch(runID: runID) + } + if let existing = attempts.first(where: { $0.attemptID == attempt.attemptID }) { + guard existing == attempt else { + throw ReviewThreadRetentionRegistryError.attemptIdentityConflict( + runID: runID, + attemptID: attempt.attemptID + ) + } + return + } + attempts.append(attempt) + } + + package mutating func merge(_ other: ReviewThreadRetentionEntry) throws { + guard runID == other.runID else { + preconditionFailure("Only entries for the same review run can be merged.") + } + for attempt in other.attempts { + try merge(attempt, scope: other.scope) + } + mergeAdditionalCleanupThreadIDs(other.additionalCleanupThreadIDs) + } + + package mutating func mergeAdditionalCleanupThreadIDs(_ threadIDs: [ReviewThreadID]) { + var seen = Set(additionalCleanupThreadIDs) + for threadID in threadIDs where seen.insert(threadID).inserted { + additionalCleanupThreadIDs.append(threadID) + } + } + + private enum CodingKeys: String, CodingKey { + case runID + case scope + case attempts + case additionalCleanupThreadIDs + } + + package init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let runID = try container.decode(ReviewRunID.self, forKey: .runID) + let attempts = try container.decode([ReviewAttempt].self, forKey: .attempts) + do { + self.runID = runID + self.scope = try container.decode(ReviewThreadRetentionScope.self, forKey: .scope) + self.attempts = try Self.validatedAttempts(attempts, runID: runID) + self.additionalCleanupThreadIDs = Self.deduplicated( + try container.decodeIfPresent( + [ReviewThreadID].self, + forKey: .additionalCleanupThreadIDs + ) ?? [] + ) + } catch let error as ReviewThreadRetentionRegistryError { + throw DecodingError.dataCorruptedError( + forKey: .attempts, + in: container, + debugDescription: error.message + ) + } + } + + private static func validatedAttempts( + _ attempts: [ReviewAttempt], + runID: ReviewRunID + ) throws -> [ReviewAttempt] { + guard let first = attempts.first else { + throw ReviewThreadRetentionRegistryError.emptyEntry(runID: runID) + } + guard attempts.allSatisfy({ + $0.threadIdentity.sourceThreadID == first.threadIdentity.sourceThreadID + }) else { + throw ReviewThreadRetentionRegistryError.sourceThreadMismatch(runID: runID) + } + var attemptsByID: [ReviewAttemptID: ReviewAttempt] = [:] + var validated: [ReviewAttempt] = [] + for attempt in attempts { + if let existing = attemptsByID[attempt.attemptID] { + guard existing == attempt else { + throw ReviewThreadRetentionRegistryError.attemptIdentityConflict( + runID: runID, + attemptID: attempt.attemptID + ) + } + continue + } + attemptsByID[attempt.attemptID] = attempt + validated.append(attempt) + } + return validated + } + + private static func deduplicated(_ threadIDs: [ReviewThreadID]) -> [ReviewThreadID] { + var seen: Set = [] + return threadIDs.filter { seen.insert($0).inserted } + } +} + +package struct ReviewThreadRetentionJournalSnapshot: Codable, Equatable, Sendable { + package var entries: [ReviewThreadRetentionEntry] + + package init(entries: [ReviewThreadRetentionEntry] = []) { + self.entries = entries + } +} + +package protocol ReviewThreadRetentionJournaling: Sendable { + func load() async throws -> ReviewThreadRetentionJournalSnapshot + func replace(with snapshot: ReviewThreadRetentionJournalSnapshot) async throws +} + +package actor InMemoryReviewThreadRetentionJournal: ReviewThreadRetentionJournaling { + private var snapshot: ReviewThreadRetentionJournalSnapshot + + package init(snapshot: ReviewThreadRetentionJournalSnapshot = .init()) { + self.snapshot = snapshot + } + + package func load() -> ReviewThreadRetentionJournalSnapshot { + snapshot + } + + package func replace(with snapshot: ReviewThreadRetentionJournalSnapshot) { + self.snapshot = snapshot + } +} + +package actor FileReviewThreadRetentionJournal: ReviewThreadRetentionJournaling { + private struct Payload: Codable { + var version: Int + var entries: [ReviewThreadRetentionEntry] + } + + private let fileURL: URL + + package init(fileURL: URL) { + self.fileURL = fileURL + } + + package func load() throws -> ReviewThreadRetentionJournalSnapshot { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .init() + } + let payload = try JSONDecoder().decode(Payload.self, from: Data(contentsOf: fileURL)) + guard payload.version == 1 else { + throw ReviewThreadRetentionRegistryError.unsupportedJournalVersion(payload.version) + } + return .init(entries: payload.entries) + } + + package func replace(with snapshot: ReviewThreadRetentionJournalSnapshot) throws { + let directoryURL = fileURL.deletingLastPathComponent() + let directoryExisted = FileManager.default.fileExists(atPath: directoryURL.path) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + if directoryExisted == false { + try Self.synchronizeDirectory(at: directoryURL.deletingLastPathComponent()) + } + if snapshot.entries.isEmpty { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return + } + try FileManager.default.removeItem(at: fileURL) + try Self.synchronizeDirectory(at: directoryURL) + return + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let payload = Payload(version: 1, entries: snapshot.entries) + let data = try encoder.encode(payload) + let replacementURL = directoryURL.appendingPathComponent( + ".\(fileURL.lastPathComponent).replacement-\(UUID().uuidString)", + isDirectory: false + ) + guard FileManager.default.createFile( + atPath: replacementURL.path, + contents: nil, + attributes: [.posixPermissions: 0o600] + ) else { + throw ReviewThreadRetentionRegistryError.journal( + message: "Could not create review retention journal replacement file." + ) + } + do { + let replacement = try FileHandle(forWritingTo: replacementURL) + try replacement.write(contentsOf: data) + try replacement.synchronize() + try replacement.close() + if FileManager.default.fileExists(atPath: fileURL.path) { + _ = try FileManager.default.replaceItemAt(fileURL, withItemAt: replacementURL) + } else { + try FileManager.default.moveItem(at: replacementURL, to: fileURL) + } + let persisted = try FileHandle(forWritingTo: fileURL) + try persisted.synchronize() + try persisted.close() + try Self.synchronizeDirectory(at: directoryURL) + } catch { + try? FileManager.default.removeItem(at: replacementURL) + throw error + } + } + + private nonisolated static func synchronizeDirectory(at url: URL) throws { + let descriptor = open(url.path, O_RDONLY) + guard descriptor >= 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + defer { Darwin.close(descriptor) } + guard fsync(descriptor) == 0 else { + throw POSIXError(.init(rawValue: errno) ?? .EIO) + } + } +} + +package enum ReviewThreadRetentionRegistryError: Error, Equatable, Sendable { + case journal(message: String) + case scopeMismatch(runID: ReviewRunID) + case sourceThreadMismatch(runID: ReviewRunID) + case emptyEntry(runID: ReviewRunID) + case attemptIdentityConflict(runID: ReviewRunID, attemptID: ReviewAttemptID) + case unsupportedJournalVersion(Int) + + package var message: String { + switch self { + case .journal(let message): + message + case .scopeMismatch(let runID): + "Review run \(runID.rawValue) changed retention scope." + case .sourceThreadMismatch(let runID): + "Review run \(runID.rawValue) changed source thread identity." + case .emptyEntry(let runID): + "Review run \(runID.rawValue) has no retained attempt identity." + case .attemptIdentityConflict(let runID, let attemptID): + "Review run \(runID.rawValue) has conflicting identity for attempt \(attemptID.rawValue)." + case .unsupportedJournalVersion(let version): + "Unsupported review retention journal version \(version)." + } + } +} + +package struct ReviewThreadRetentionQuarantine: Equatable, Sendable { + package let entry: ReviewThreadRetentionEntry + package let journalFailure: String + package let cleanupFailure: String +} + +package enum ReviewThreadRetentionAcceptance: Equatable, Sendable { + case accepting + case quarantined([ReviewThreadRetentionQuarantine]) + case journalUnavailable(message: String) + + package var isAccepting: Bool { + if case .accepting = self { + return true + } + return false + } +} + +package enum ReviewThreadOrphanRecoveryResult: Equatable, Sendable { + case recovered + case cleanupIncomplete + case journalUnavailable(message: String) +} + +package actor ReviewThreadRetentionRegistry { + private let journal: any ReviewThreadRetentionJournaling + private var didLoadJournal = false + private var persistedEntriesByRunID: [ReviewRunID: ReviewThreadRetentionEntry] = [:] + private var pendingOwnershipByRunID: [ReviewRunID: ReviewThreadRetentionEntry] = [:] + private var quarantinesByRunID: [ReviewRunID: ReviewThreadRetentionQuarantine] = [:] + + package init(journal: any ReviewThreadRetentionJournaling) { + self.journal = journal + } + + package func acceptance() async -> ReviewThreadRetentionAcceptance { + guard quarantinesByRunID.isEmpty else { + return .quarantined(orderedQuarantines()) + } + do { + try await loadJournalIfNeeded() + return .accepting + } catch { + return .journalUnavailable( + message: (error as? ReviewThreadRetentionRegistryError)?.message + ?? error.localizedDescription + ) + } + } + + package func claim( + _ attempt: ReviewAttempt, + for runID: ReviewRunID, + scope: ReviewThreadRetentionScope + ) async throws { + let existingCandidate = pendingOwnershipByRunID[runID] + ?? persistedEntriesByRunID[runID] + var candidate: ReviewThreadRetentionEntry + if let existingCandidate { + candidate = existingCandidate + try candidate.merge(attempt, scope: scope) + } else { + candidate = ReviewThreadRetentionEntry(runID: runID, scope: scope, attempts: [attempt]) + } + pendingOwnershipByRunID[runID] = candidate + + do { + try await loadJournalIfNeeded() + if var persisted = persistedEntriesByRunID[runID] { + try persisted.merge(candidate) + candidate = persisted + pendingOwnershipByRunID[runID] = candidate + } + if persistedEntriesByRunID[runID] == candidate, + quarantinesByRunID[runID] == nil + { + pendingOwnershipByRunID.removeValue(forKey: runID) + return + } + var desired = persistedEntriesByRunID + desired[runID] = candidate + try await journal.replace(with: Self.snapshot(desired)) + persistedEntriesByRunID = desired + pendingOwnershipByRunID.removeValue(forKey: runID) + quarantinesByRunID.removeValue(forKey: runID) + } catch let error as ReviewThreadRetentionRegistryError { + throw error + } catch { + throw ReviewThreadRetentionRegistryError.journal(message: error.localizedDescription) + } + } + + package func recordFailedClaimCleanup( + runID: ReviewRunID, + journalFailure: String, + failedThreadIDs: [ReviewThreadID], + cleanupFailure: String? + ) { + guard let pending = pendingOwnershipByRunID[runID] else { + preconditionFailure("A failed retention claim must keep pending ownership until rollback resolves.") + } + guard let cleanupFailure else { + pendingOwnershipByRunID.removeValue(forKey: runID) + quarantinesByRunID.removeValue(forKey: runID) + return + } + var quarantinedEntry = pending + quarantinedEntry.mergeAdditionalCleanupThreadIDs(failedThreadIDs) + pendingOwnershipByRunID[runID] = quarantinedEntry + quarantinesByRunID[runID] = .init( + entry: quarantinedEntry, + journalFailure: journalFailure, + cleanupFailure: cleanupFailure + ) + } + + package func retryQuarantinedJournalCommits() async -> ReviewThreadRetentionAcceptance { + guard quarantinesByRunID.isEmpty == false else { + return await acceptance() + } + do { + try await loadJournalIfNeeded() + var desired = persistedEntriesByRunID + for (runID, quarantine) in quarantinesByRunID { + if var current = desired[runID] { + try current.merge(quarantine.entry) + desired[runID] = current + } else { + desired[runID] = quarantine.entry + } + } + try await journal.replace(with: Self.snapshot(desired)) + persistedEntriesByRunID = desired + for runID in quarantinesByRunID.keys { + pendingOwnershipByRunID.removeValue(forKey: runID) + } + quarantinesByRunID.removeAll(keepingCapacity: false) + return .accepting + } catch { + return .quarantined(orderedQuarantines()) + } + } + + package func quarantinedEntries() -> [ReviewThreadRetentionEntry] { + orderedQuarantines().map(\.entry) + } + + package func pendingEntry(for runID: ReviewRunID) -> ReviewThreadRetentionEntry? { + pendingOwnershipByRunID[runID] + } + + package func entriesForFinalRetirement( + matchingCodexHome scope: ReviewThreadRetentionScope + ) async throws -> [ReviewThreadRetentionEntry] { + try await loadJournalIfNeeded() + return persistedEntriesByRunID.values + .filter { $0.scope.matchesCodexHome(of: scope) } + .sorted(by: Self.entryOrder) + } + + package func orphanedEntries( + excluding liveRunIDs: Set, + matchingCodexHome scope: ReviewThreadRetentionScope + ) async throws -> [ReviewThreadRetentionEntry] { + try await loadJournalIfNeeded() + return persistedEntriesByRunID.values + .filter { + liveRunIDs.contains($0.runID) == false + && $0.scope.matchesCodexHome(of: scope) + } + .sorted(by: Self.entryOrder) + } + + package func recordCleanupSucceeded(for runID: ReviewRunID) async { + pendingOwnershipByRunID.removeValue(forKey: runID) + quarantinesByRunID.removeValue(forKey: runID) + guard persistedEntriesByRunID[runID] != nil else { + return + } + var desired = persistedEntriesByRunID + desired.removeValue(forKey: runID) + do { + try await journal.replace(with: Self.snapshot(desired)) + persistedEntriesByRunID = desired + } catch { + // The durable tombstone is intentionally retained. Cleanup is idempotent, + // so startup recovery can repeat it and retry journal removal. + } + } + + package func recordCleanupFailed( + for runID: ReviewRunID, + failedThreadIDs: [ReviewThreadID], + message: String + ) async -> Bool { + guard failedThreadIDs.isEmpty == false else { + preconditionFailure("A retained cleanup failure requires at least one failed thread identity.") + } + guard var entry = persistedEntriesByRunID[runID] else { + if var quarantine = quarantinesByRunID[runID] { + var quarantinedEntry = quarantine.entry + quarantinedEntry.mergeAdditionalCleanupThreadIDs(failedThreadIDs) + pendingOwnershipByRunID[runID] = quarantinedEntry + quarantine = .init( + entry: quarantinedEntry, + journalFailure: quarantine.journalFailure, + cleanupFailure: message + ) + quarantinesByRunID[runID] = quarantine + } + return false + } + entry.mergeAdditionalCleanupThreadIDs(failedThreadIDs) + var desired = persistedEntriesByRunID + desired[runID] = entry + do { + try await journal.replace(with: Self.snapshot(desired)) + persistedEntriesByRunID = desired + return true + } catch { + pendingOwnershipByRunID[runID] = entry + quarantinesByRunID[runID] = .init( + entry: entry, + journalFailure: error.localizedDescription, + cleanupFailure: message + ) + return false + } + } + + package func snapshotForTesting() async throws -> ReviewThreadRetentionJournalSnapshot { + try await loadJournalIfNeeded() + return Self.snapshot(persistedEntriesByRunID) + } + + private func loadJournalIfNeeded() async throws { + guard didLoadJournal == false else { + return + } + do { + let snapshot = try await journal.load() + persistedEntriesByRunID = try Self.entriesByRunID(snapshot.entries) + didLoadJournal = true + } catch let error as ReviewThreadRetentionRegistryError { + throw error + } catch { + throw ReviewThreadRetentionRegistryError.journal(message: error.localizedDescription) + } + } + + private func orderedQuarantines() -> [ReviewThreadRetentionQuarantine] { + quarantinesByRunID.values.sorted { + Self.entryOrder($0.entry, $1.entry) + } + } + + private nonisolated static func entriesByRunID( + _ entries: [ReviewThreadRetentionEntry] + ) throws -> [ReviewRunID: ReviewThreadRetentionEntry] { + var result: [ReviewRunID: ReviewThreadRetentionEntry] = [:] + for entry in entries { + if var existing = result[entry.runID] { + try existing.merge(entry) + result[entry.runID] = existing + } else { + result[entry.runID] = entry + } + } + return result + } + + private nonisolated static func snapshot( + _ entriesByRunID: [ReviewRunID: ReviewThreadRetentionEntry] + ) -> ReviewThreadRetentionJournalSnapshot { + .init(entries: entriesByRunID.values.sorted(by: entryOrder)) + } + + private nonisolated static func entryOrder( + _ lhs: ReviewThreadRetentionEntry, + _ rhs: ReviewThreadRetentionEntry + ) -> Bool { + lhs.runID.rawValue < rhs.runID.rawValue + } +} + +extension CodexReviewStore { + package var currentReviewThreadRetentionScope: ReviewThreadRetentionScope { + ReviewThreadRetentionScope( + codexHomePath: backend.reviewThreadRetentionCodexHomePath, + accountKey: auth.selectedAccount?.accountKey ?? auth.persistedActiveAccountKey + ) + } + + package func requireReviewThreadRetentionAcceptance() async throws { + switch await reviewThreadRetentionRegistry.acceptance() { + case .accepting: + return + case .quarantined(let quarantines): + let runIDs = quarantines.map { $0.entry.runID.rawValue }.joined(separator: ", ") + throw ReviewBackendFailure.retentionJournal( + message: "Review thread retention recovery is quarantined for run(s): \(runIDs)." + ) + case .journalUnavailable(let message): + throw ReviewBackendFailure.retentionJournal(message: message) + } + } + + package func claimReviewThreadOwnership( + _ attempt: ReviewAttempt, + for runID: ReviewRunID + ) async throws { + do { + try await reviewThreadRetentionRegistry.claim( + attempt, + for: runID, + scope: currentReviewThreadRetentionScope + ) + } catch { + let journalFailure = (error as? ReviewThreadRetentionRegistryError)?.message + ?? error.localizedDescription + try? await backend.interruptReview( + attempt, + reason: .init(message: "Review retention journal commit failed.") + ) + await backend.cleanupReview(attempt) + let pending = await reviewThreadRetentionRegistry.pendingEntry(for: runID) + let cleanup = await backend.cleanupRetainedReviews( + pending?.attempts ?? [attempt], + additionalThreadIDs: pending?.additionalCleanupThreadIDs ?? [] + ) + await reviewThreadRetentionRegistry.recordFailedClaimCleanup( + runID: runID, + journalFailure: journalFailure, + failedThreadIDs: cleanup.failures.map(\.threadID), + cleanupFailure: cleanup.failureMessage + ) + throw ReviewBackendFailure.retentionJournal( + message: "Review identity could not be committed to the retention journal: \(journalFailure)" + ) + } + } + + package func recoverOrphanedReviewThreads() async -> ReviewThreadOrphanRecoveryResult { + let scope = currentReviewThreadRetentionScope + let liveRunIDs = Set(reviewRuns.map(\.id)) + let entries: [ReviewThreadRetentionEntry] + do { + entries = try await reviewThreadRetentionRegistry.orphanedEntries( + excluding: liveRunIDs, + matchingCodexHome: scope + ) + } catch { + return .journalUnavailable( + message: (error as? ReviewThreadRetentionRegistryError)?.message + ?? error.localizedDescription + ) + } + var allCleaned = true + var journalRemainsAvailable = true + for entry in entries { + let result = await backend.cleanupRetainedReviews( + entry.attempts, + additionalThreadIDs: entry.additionalCleanupThreadIDs + ) + if result.succeeded { + await reviewThreadRetentionRegistry.recordCleanupSucceeded(for: entry.runID) + } else { + let didPersistFailure = await reviewThreadRetentionRegistry.recordCleanupFailed( + for: entry.runID, + failedThreadIDs: result.failures.map(\.threadID), + message: result.failureMessage ?? "Review thread cleanup failed." + ) + journalRemainsAvailable = journalRemainsAvailable && didPersistFailure + allCleaned = false + } + } + if journalRemainsAvailable == false { + return .journalUnavailable( + message: "Review cleanup failures could not be committed to the retention journal." + ) + } + return allCleaned ? .recovered : .cleanupIncomplete + } + + @discardableResult + package func retireReviewRunsForFinalStoreStop() async -> Bool { + _ = await reviewThreadRetentionRegistry.retryQuarantinedJournalCommits() + let currentScope = currentReviewThreadRetentionScope + let entries: [ReviewThreadRetentionEntry] + do { + entries = try await reviewThreadRetentionRegistry.entriesForFinalRetirement( + matchingCodexHome: currentScope + ) + } catch { + return false + } + let quarantined = await reviewThreadRetentionRegistry.quarantinedEntries() + let quarantinedRunIDs = Set(quarantined.map(\.runID)) + + reviewRuns.removeAll(keepingCapacity: false) + writeDiagnosticsIfNeeded() + + for entry in quarantined { + guard entry.scope.matchesCodexHome(of: currentScope) else { + return false + } + let cleanup = await backend.cleanupRetainedReviews( + entry.attempts, + additionalThreadIDs: entry.additionalCleanupThreadIDs + ) + guard cleanup.succeeded else { + _ = await reviewThreadRetentionRegistry.recordCleanupFailed( + for: entry.runID, + failedThreadIDs: cleanup.failures.map(\.threadID), + message: cleanup.failureMessage ?? "Review thread cleanup failed." + ) + continue + } + await reviewThreadRetentionRegistry.recordCleanupSucceeded(for: entry.runID) + } + guard await reviewThreadRetentionRegistry.acceptance().isAccepting else { + return false + } + + for entry in entries + where entry.scope.matchesCodexHome(of: currentScope) + && quarantinedRunIDs.contains(entry.runID) == false + { + let result = await backend.cleanupRetainedReviews( + entry.attempts, + additionalThreadIDs: entry.additionalCleanupThreadIDs + ) + if result.succeeded { + await reviewThreadRetentionRegistry.recordCleanupSucceeded(for: entry.runID) + } else if await reviewThreadRetentionRegistry.recordCleanupFailed( + for: entry.runID, + failedThreadIDs: result.failures.map(\.threadID), + message: result.failureMessage ?? "Review thread cleanup failed." + ) == false { + return false + } + } + return true + } +} diff --git a/Sources/CodexReviewKit/Store/ReviewWorkerClock.swift b/Sources/CodexReviewKit/Store/ReviewWorkerClock.swift new file mode 100644 index 00000000..8949aefb --- /dev/null +++ b/Sources/CodexReviewKit/Store/ReviewWorkerClock.swift @@ -0,0 +1,32 @@ +import Foundation + +package struct ReviewWorkerClock: Sendable { + package typealias Instant = ContinuousClock.Instant + + private let nowValue: @Sendable () -> Instant + private let sleepValue: @Sendable (Duration) async throws -> Void + + package init( + now: @escaping @Sendable () -> Instant, + sleep: @escaping @Sendable (Duration) async throws -> Void + ) { + nowValue = now + sleepValue = sleep + } + + package var now: Instant { + nowValue() + } + + package func sleep(for duration: Duration) async throws { + try await sleepValue(duration) + } + + package static let continuous: Self = { + let clock = ContinuousClock() + return .init( + now: { clock.now }, + sleep: { try await clock.sleep(for: $0) } + ) + }() +} diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift index 529e0071..65712779 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift @@ -3,12 +3,89 @@ import Foundation import MCP import OSLog import Synchronization +import CodexReviewKit @preconcurrency import NIOCore @preconcurrency import NIOHTTP1 @preconcurrency import NIOPosix private let logger = Logger(subsystem: "CodexReviewKit", category: "mcp-http") +package typealias MCPHTTPServerLifetime = CodexReviewMCPHTTPServer + +package typealias MCPProtocolServerFactory = @MainActor @Sendable ( + CodexReviewMCPServer, + String, + MCPReviewSessionRegistry, + MCPClientSessionState, + Duration +) async -> Server + +package struct MCPHTTPServerClock: Sendable { + package struct Instant: Comparable, Sendable { + package static let zero = Self(elapsed: .zero) + + fileprivate let elapsed: Duration + + package func advanced(by duration: Duration) -> Self { + Self(elapsed: elapsed + duration) + } + + package func duration(to other: Self) -> Duration { + other.elapsed - elapsed + } + + package static func < (lhs: Self, rhs: Self) -> Bool { + lhs.elapsed < rhs.elapsed + } + } + + private let readNow: @Sendable () -> Instant + private let sleepForDuration: + @Sendable (Duration) async throws(CancellationError) -> Void + + package init( + now: @escaping @Sendable () -> Instant, + sleep: @escaping @Sendable (Duration) async throws(CancellationError) -> Void + ) { + self.readNow = now + self.sleepForDuration = sleep + } + + package static func continuous() -> Self { + let clock = ContinuousClock() + let origin = clock.now + return Self( + now: { + Instant(elapsed: origin.duration(to: clock.now)) + }, + sleep: { (duration: Duration) async throws(CancellationError) -> Void in + try await Self.sleep(using: clock, for: duration) + } + ) + } + + package var now: Instant { + readNow() + } + + package func sleep(for duration: Duration) async throws(CancellationError) { + try await sleepForDuration(duration) + } + + private static func sleep( + using clock: ContinuousClock, + for duration: Duration + ) async throws(CancellationError) { + do { + try await clock.sleep(for: duration) + } catch is CancellationError { + throw CancellationError() + } catch { + preconditionFailure("ContinuousClock.sleep failed outside task cancellation: \(error)") + } + } +} + private struct TrackedHTTPResponse { var response: HTTPResponse var streamCompletion: ActiveRequestCompletion? = nil @@ -47,26 +124,32 @@ package extension CodexReviewMCPHTTPServer { package var host: String package var port: Int package var endpoint: String - package var sessionTimeout: TimeInterval + package var sessionTimeout: Duration + package var sessionCleanupInterval: Duration package var retryInterval: Int? package var streamHeartbeatInterval: Duration? package var boundedReviewWaitDuration: Duration + package var sessionClock: MCPHTTPServerClock package init( host: String = "localhost", port: Int = 9417, endpoint: String = "/mcp", - sessionTimeout: TimeInterval = 3600, - retryInterval: Int? = 1000 + sessionTimeout: Duration = .seconds(3600), + sessionCleanupInterval: Duration = .seconds(60), + retryInterval: Int? = 1000, + sessionClock: MCPHTTPServerClock = .continuous() ) { self.init( host: host, port: port, endpoint: endpoint, sessionTimeout: sessionTimeout, + sessionCleanupInterval: sessionCleanupInterval, retryInterval: retryInterval, streamHeartbeatInterval: .seconds(30), - boundedReviewWaitDuration: .seconds(540) + boundedReviewWaitDuration: .seconds(540), + sessionClock: sessionClock ) } @@ -74,18 +157,27 @@ package extension CodexReviewMCPHTTPServer { host: String = "localhost", port: Int = 9417, endpoint: String = "/mcp", - sessionTimeout: TimeInterval = 3600, + sessionTimeout: Duration = .seconds(3600), + sessionCleanupInterval: Duration = .seconds(60), retryInterval: Int? = 1000, streamHeartbeatInterval: Duration?, - boundedReviewWaitDuration: Duration = .seconds(540) + boundedReviewWaitDuration: Duration = .seconds(540), + sessionClock: MCPHTTPServerClock = .continuous() ) { + precondition(sessionTimeout >= .zero, "The MCP session timeout cannot be negative.") + precondition( + sessionCleanupInterval > .zero, + "The MCP session cleanup interval must be positive." + ) self.host = host self.port = port self.endpoint = endpoint.hasPrefix("/") ? endpoint : "/\(endpoint)" self.sessionTimeout = sessionTimeout + self.sessionCleanupInterval = sessionCleanupInterval self.retryInterval = retryInterval self.streamHeartbeatInterval = streamHeartbeatInterval self.boundedReviewWaitDuration = boundedReviewWaitDuration + self.sessionClock = sessionClock } package func url(boundPort: Int? = nil) -> URL { @@ -97,15 +189,47 @@ package extension CodexReviewMCPHTTPServer { return components.url! } } + + struct ResourceSnapshot: Equatable, Sendable { + package var listenerCount: Int + package var eventLoopGroupCount: Int + package var sessionCount: Int + package var registrySessionCount: Int + package var cleanupTaskCount: Int + package var requestPumpTaskCount: Int + package var activeRequestWorkCount: Int + package var childChannelCount: Int + } } package actor CodexReviewMCPHTTPServer { + private enum Phase { + case idle + case staging + case staged + case accepting + case stopping(Task) + case stopped + } + private struct SessionContext { - let server: Server + enum Phase { + case initializing + case open + case closing( + reason: MCPReviewSessionCloseReason, + completion: Task + ) + } + + var phase: Phase + var server: Server? let transport: StatefulHTTPServerTransport - let createdAt: Date - var lastAccessedAt: Date + var idleSince: MCPHTTPServerClock.Instant? var activeRequestCount: Int + var registryOpened: Bool + var creationCompleted: Bool + var initializationResponseReady: Bool } private struct FixedSessionIDGenerator: SessionIDGenerator { @@ -117,18 +241,51 @@ package actor CodexReviewMCPHTTPServer { } private let adapter: CodexReviewMCPServer + private let sessionRegistry: MCPReviewSessionRegistry + private let protocolServerFactory: MCPProtocolServerFactory private let configuration: CodexReviewMCPHTTPServer.Configuration private var channel: Channel? private var eventLoopGroup: MultiThreadedEventLoopGroup? private var sessions: [String: SessionContext] = [:] + private var stagingWaiters: [CheckedContinuation] = [] + private var creationWaiters: [String: [CheckedContinuation]] = [:] + private var requestDrainWaiters: [String: [CheckedContinuation]] = [:] private var cleanupTask: Task? + private let requestEventSink = MCPHTTPRequestEventSink() + private var requestPumpTask: Task? + private var activeRequestWorkCount = 0 + private let childChannels = MCPHTTPChannelRegistry() private var boundURL: URL? + private var phase: Phase = .idle package init( adapter: CodexReviewMCPServer, - configuration: CodexReviewMCPHTTPServer.Configuration = .init() + configuration: CodexReviewMCPHTTPServer.Configuration = .init(), + protocolServerFactory: @escaping MCPProtocolServerFactory = { + adapter, + sessionID, + sessionRegistry, + clientSession, + boundedReviewWaitDuration in + await makeMCPProtocolServer( + adapter: adapter, + sessionID: sessionID, + sessionRegistry: sessionRegistry, + clientSession: clientSession, + boundedReviewWaitDuration: boundedReviewWaitDuration + ) + } ) { self.adapter = adapter + self.sessionRegistry = MCPReviewSessionRegistry( + closeStoreSession: { sessionID in + await adapter.closeSession(sessionID) + }, + releaseStoreSession: { sessionID in + adapter.releaseClosedSession(sessionID) + } + ) + self.protocolServerFactory = protocolServerFactory self.configuration = configuration } @@ -171,17 +328,48 @@ package actor CodexReviewMCPHTTPServer { } package func start() async throws { - guard channel == nil else { + try await stage() + switch phase { + case .staged: + phase = .accepting + case .stopping(let completion): + await completion.value + case .stopped: return + case .idle, .staging, .accepting: + preconditionFailure("MCP HTTP staging must complete before activation.") } + } + package func stage() async throws { + guard case .idle = phase else { + preconditionFailure("The MCP HTTP server lifetime can only be started once.") + } + phase = .staging + + requestPumpTask = Task { [self] in + await runRequestPump() + } + let endpoint = configuration.endpoint + let heartbeatInterval = configuration.streamHeartbeatInterval + let eventSink = requestEventSink + let childChannels = childChannels let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let bootstrap = ServerBootstrap(group: group) .serverChannelOption(ChannelOptions.backlog, value: 128) .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler(CodexReviewMCPHTTPHandler(server: self)) + childChannels.insert(channel) + channel.closeFuture.whenComplete { _ in + childChannels.remove(channel) + } + return channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler(CodexReviewMCPHTTPHandler( + server: self, + endpoint: endpoint, + heartbeatInterval: heartbeatInterval, + eventSink: eventSink + )) } } .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) @@ -197,12 +385,31 @@ package actor CodexReviewMCPHTTPServer { self.channel = channel let actualPort = channel.localAddress?.port boundURL = configuration.url(boundPort: actualPort) + phase = .staged + let clock = configuration.sessionClock + let cleanupInterval = configuration.sessionCleanupInterval cleanupTask = Task { [weak self] in - await self?.sessionCleanupLoop() + while Task.isCancelled == false { + do { + try await clock.sleep(for: cleanupInterval) + } catch { + return + } + guard Task.isCancelled == false else { + return + } + await self?.runScheduledSessionCleanup() + } } + finishStaging() logger.info("MCP Streamable HTTP server listening at \(self.url.absoluteString, privacy: .public)") } catch { + requestEventSink.finish() + await requestPumpTask?.value + requestPumpTask = nil try? await group.shutdownGracefully() + phase = .stopped + finishStaging() throw CodexReviewMCPHTTPServer.Error.classifyStartError( error, configuration: configuration @@ -210,35 +417,147 @@ package actor CodexReviewMCPHTTPServer { } } + package func activate() { + guard case .staged = phase else { + preconditionFailure("Only a staged MCP HTTP server can begin accepting requests.") + } + phase = .accepting + } + package func stop() async { - cleanupTask?.cancel() + let completion: Task + switch phase { + case .idle: + phase = .stopped + return + case .staging: + await waitForStaging() + await stop() + return + case .staged, .accepting: + let task = Task { [self] in + await performStop() + } + phase = .stopping(task) + completion = task + case .stopping(let task): + completion = task + case .stopped: + return + } + await completion.value + } + + private func waitForStaging() async { + guard case .staging = phase else { + return + } + await withCheckedContinuation { continuation in + stagingWaiters.append(continuation) + } + } + + private func finishStaging() { + let waiters = stagingWaiters + stagingWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func performStop() async { + let cleanup = cleanupTask cleanupTask = nil - await closeAllSessions() - try? await channel?.close() + cleanup?.cancel() + + if let channel { + try? await channel.close() + } channel = nil + await cleanup?.value + + await closeAllSessions(reason: .serverStop) + + requestEventSink.finish() + let requestPump = requestPumpTask + requestPumpTask = nil + await requestPump?.value + + let openChildChannels = childChannels.snapshot() + for childChannel in openChildChannels { + try? await childChannel.close() + } + if let eventLoopGroup { try? await eventLoopGroup.shutdownGracefully() } eventLoopGroup = nil boundURL = nil + phase = .stopped logger.info("MCP Streamable HTTP server stopped") } - package func handleHTTPRequest(_ request: HTTPRequest) async -> HTTPResponse { - await handleTrackedHTTPRequest(request).response + private func runRequestPump() async { + await withTaskGroup(of: Void.self) { group in + for await work in requestEventSink.events { + activeRequestWorkCount += 1 + group.addTask { + await work.run() + await self.requestWorkFinished() + } + } + while await group.next() != nil {} + } + } + + private func requestWorkFinished() { + precondition(activeRequestWorkCount > 0) + activeRequestWorkCount -= 1 + } + + package func handleHTTPRequestForTesting(_ request: HTTPRequest) async -> HTTPResponse { + let trackedResponse = await handleTrackedHTTPRequest(request) + trackedResponse.streamCompletion?.finish() + return trackedResponse.response } fileprivate func handleTrackedHTTPRequest(_ request: HTTPRequest) async -> TrackedHTTPResponse { + guard case .accepting = phase else { + return .init( + response: .error( + statusCode: 503, + .internalError("MCP HTTP server is stopping") + ) + ) + } let sessionID = request.header(HTTPHeaderName.sessionID) if let sessionID, var session = sessions[sessionID] { - session.lastAccessedAt = Date() + switch session.phase { + case .initializing: + return .init( + response: .error( + statusCode: 409, + .invalidRequest("Conflict: MCP session initialization is not complete") + ) + ) + case .closing: + return .init( + response: .error( + statusCode: 404, + .invalidRequest("Not Found: Session not found or expired") + ) + ) + case .open: + break + } + session.idleSince = nil session.activeRequestCount += 1 sessions[sessionID] = session let response = await session.transport.handleRequest(request) let (trackedResponse, didFinishRequest) = trackActiveRequest(response, sessionID: sessionID) if didFinishRequest, request.method.uppercased() == "DELETE", trackedResponse.response.statusCode == 200 { - await closeSession(sessionID) + await closeSession(sessionID, reason: .delete) } return trackedResponse } @@ -269,34 +588,75 @@ package actor CodexReviewMCPHTTPServer { validationPipeline: makeValidationPipeline(), retryInterval: configuration.retryInterval ) + sessions[sessionID] = SessionContext( + phase: .initializing, + server: nil, + transport: transport, + idleSince: nil, + activeRequestCount: 1, + registryOpened: false, + creationCompleted: false, + initializationResponseReady: false + ) + defer { + finishSessionCreation(sessionID) + } do { - let server = await makeMCPProtocolServer( - adapter: adapter, - defaultSessionID: sessionID, - clientSession: clientSession, - boundedReviewWaitDuration: configuration.boundedReviewWaitDuration + await sessionRegistry.openSession(sessionID) + updateSession(sessionID) { context in + context.registryOpened = true + } + guard isSessionInitializing(sessionID) else { + await transport.disconnect() + finishActiveRequest(sessionID: sessionID) + return sessionClosedDuringInitializationResponse() + } + + let server = await protocolServerFactory( + adapter, + sessionID, + sessionRegistry, + clientSession, + configuration.boundedReviewWaitDuration ) + updateSession(sessionID) { context in + context.server = server + } + guard isSessionInitializing(sessionID) else { + await server.stop() + await transport.disconnect() + finishActiveRequest(sessionID: sessionID) + return sessionClosedDuringInitializationResponse() + } + try await server.start(transport: transport) { clientInfo, _ in await clientSession.update(clientInfo: clientInfo) } - sessions[sessionID] = SessionContext( - server: server, - transport: transport, - createdAt: Date(), - lastAccessedAt: Date(), - activeRequestCount: 1 - ) + guard isSessionInitializing(sessionID) else { + await server.stop() + await transport.disconnect() + finishActiveRequest(sessionID: sessionID) + return sessionClosedDuringInitializationResponse() + } let response = await transport.handleRequest(request) + guard isSessionInitializing(sessionID) else { + finishActiveRequest(sessionID: sessionID) + return sessionClosedDuringInitializationResponse() + } let (trackedResponse, didFinishRequest) = trackActiveRequest(response, sessionID: sessionID) if didFinishRequest, case .error = trackedResponse.response { - sessions.removeValue(forKey: sessionID) - await transport.disconnect() + _ = beginCloseSession(sessionID, reason: .initializationFailure) + } else { + updateSession(sessionID) { context in + context.initializationResponseReady = true + } } return trackedResponse } catch { - await transport.disconnect() + finishActiveRequest(sessionID: sessionID) + _ = beginCloseSession(sessionID, reason: .initializationFailure) return .init( response: .error( statusCode: 500, @@ -306,50 +666,172 @@ package actor CodexReviewMCPHTTPServer { } } - private func closeSession(_ sessionID: String) async { - guard let session = sessions.removeValue(forKey: sessionID) else { + private func closeSession( + _ sessionID: String, + reason: MCPReviewSessionCloseReason + ) async { + guard let completion = beginCloseSession(sessionID, reason: reason) else { return } - await session.transport.disconnect() - await adapter.closeSession(sessionID) + await completion.value + } + + private func beginCloseSession( + _ sessionID: String, + reason: MCPReviewSessionCloseReason + ) -> Task? { + guard var context = sessions[sessionID] else { + return nil + } + switch context.phase { + case .initializing, .open: + let completion = Task { [self] in + await driveSessionClose(sessionID: sessionID, reason: reason) + } + context.phase = .closing(reason: reason, completion: completion) + sessions[sessionID] = context + return completion + case .closing(_, let completion): + return completion + } + } + + private func driveSessionClose( + sessionID: String, + reason: MCPReviewSessionCloseReason + ) async { + guard let initialContext = sessions[sessionID] else { + return + } + + var registryClose: Task? + if initialContext.registryOpened { + registryClose = await sessionRegistry.beginClose(sessionID, reason: reason) + } + + if let server = initialContext.server { + await server.stop() + } + await initialContext.transport.disconnect() + await waitForSessionCreation(sessionID) + + // Initialization can publish a Server or registry session immediately + // before observing the close phase. Drain those late resources too. + if let finalContext = sessions[sessionID] { + if registryClose == nil, finalContext.registryOpened { + registryClose = await sessionRegistry.beginClose(sessionID, reason: reason) + } + if let server = finalContext.server, initialContext.server == nil { + await server.stop() + } + await finalContext.transport.disconnect() + } + + await waitForSessionRequests(sessionID) + if let registryClose { + let report = await registryClose.value + if report.cancellationFailed.isEmpty == false { + let runIDs = report.cancellationFailed + .map(\.rawValue) + .sorted() + .joined(separator: ",") + logger.error( + "MCP session \(sessionID, privacy: .public) closed with undrained runs \(runIDs, privacy: .public)" + ) + } + } + + let didOpenRegistry = sessions[sessionID]?.registryOpened == true + sessions.removeValue(forKey: sessionID) + creationWaiters.removeValue(forKey: sessionID) + requestDrainWaiters.removeValue(forKey: sessionID) + if didOpenRegistry { + await sessionRegistry.removeClosedSession(sessionID) + } logger.info("Closed MCP HTTP session \(sessionID, privacy: .public)") } + private func waitForSessionCreation(_ sessionID: String) async { + guard sessions[sessionID]?.creationCompleted == false else { + return + } + await withCheckedContinuation { continuation in + creationWaiters[sessionID, default: []].append(continuation) + } + } + + private func waitForSessionRequests(_ sessionID: String) async { + guard let context = sessions[sessionID], context.activeRequestCount > 0 else { + return + } + await withCheckedContinuation { continuation in + requestDrainWaiters[sessionID, default: []].append(continuation) + } + } + + private func finishSessionCreation(_ sessionID: String) { + guard var context = sessions[sessionID] else { + return + } + context.creationCompleted = true + if case .initializing = context.phase, + context.initializationResponseReady, + context.activeRequestCount == 0 { + context.phase = .open + context.idleSince = configuration.sessionClock.now + } + sessions[sessionID] = context + let waiters = creationWaiters.removeValue(forKey: sessionID) ?? [] + for waiter in waiters { + waiter.resume() + } + } + + private func isSessionInitializing(_ sessionID: String) -> Bool { + guard let context = sessions[sessionID], case .initializing = context.phase else { + return false + } + return true + } + + private func updateSession( + _ sessionID: String, + update: (inout SessionContext) -> Void + ) { + guard var context = sessions[sessionID] else { + return + } + update(&context) + sessions[sessionID] = context + } + + private func sessionClosedDuringInitializationResponse() -> TrackedHTTPResponse { + .init( + response: .error( + statusCode: 503, + .internalError("MCP session closed during initialization") + ) + ) + } + private func trackActiveRequest( _ response: HTTPResponse, sessionID: String ) -> (response: TrackedHTTPResponse, didFinishRequest: Bool) { switch response { case .stream(let stream, let headers): - let completion = ActiveRequestCompletion { - Task { + let eventSink = requestEventSink + let completion = ActiveRequestCompletion { [self] in + let didSubmit = eventSink.send(MCPHTTPRequestWork { await self.finishActiveRequest(sessionID: sessionID) - } - } - let trackedStream = AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in - let heartbeatTask = makeStreamHeartbeatTask(continuation: continuation) - let task = Task { - defer { - heartbeatTask?.cancel() - completion.finish() - } - do { - for try await chunk in stream { - continuation.yield(chunk) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in - heartbeatTask?.cancel() - task.cancel() - completion.finish() - } + }) + precondition( + didSubmit, + "The HTTP request pump must outlive every tracked MCP response stream." + ) } return ( - .init(response: .stream(trackedStream, headers: headers), streamCompletion: completion), + .init(response: .stream(stream, headers: headers), streamCompletion: completion), false ) @@ -360,83 +842,119 @@ package actor CodexReviewMCPHTTPServer { } private func finishActiveRequest(sessionID: String) { - if var session = sessions[sessionID] { - session.lastAccessedAt = Date() - session.activeRequestCount = max(0, session.activeRequestCount - 1) - sessions[sessionID] = session + guard var session = sessions[sessionID] else { + preconditionFailure("An active MCP HTTP request must retain its session context.") } - } - - private func makeStreamHeartbeatTask( - continuation: AsyncThrowingStream.Continuation - ) -> Task? { - guard let interval = configuration.streamHeartbeatInterval else { - return nil + precondition( + session.activeRequestCount > 0, + "An MCP HTTP request can only finish once." + ) + session.activeRequestCount -= 1 + if case .initializing = session.phase, + session.initializationResponseReady, + session.creationCompleted, + session.activeRequestCount == 0 { + session.phase = .open } - return Task { - while Task.isCancelled == false { - do { - try await Task.sleep(for: interval) - } catch { - return - } - guard Task.isCancelled == false else { - return - } - continuation.yield(Data(": keep-alive\n\n".utf8)) + if session.activeRequestCount == 0 { + session.idleSince = configuration.sessionClock.now + } + sessions[sessionID] = session + if session.activeRequestCount == 0 { + let waiters = requestDrainWaiters.removeValue(forKey: sessionID) ?? [] + for waiter in waiters { + waiter.resume() } } } - private func closeAllSessions() async { - for sessionID in sessions.keys { - await closeSession(sessionID) + private func closeAllSessions(reason: MCPReviewSessionCloseReason) async { + let sessionIDs = Array(sessions.keys) + let completions = sessionIDs.compactMap { sessionID in + beginCloseSession(sessionID, reason: reason) } - } - - private func sessionCleanupLoop() async { - while Task.isCancelled == false { - try? await Task.sleep(for: .seconds(60)) - guard Task.isCancelled == false else { - return - } - await closeExpiredSessions(now: Date()) + for completion in completions { + await completion.value } } - package func runSessionCleanupForTesting(now: Date) async { - await closeExpiredSessions(now: now) + private func runScheduledSessionCleanup() async { + await closeExpiredSessions(now: configuration.sessionClock.now) } package func sessionActiveRequestCountForTesting(sessionID: String) -> Int? { sessions[sessionID]?.activeRequestCount } - private func closeExpiredSessions(now: Date) async { - var expiredSessionIDs: [String] = [] - for (sessionID, context) in sessions { - guard now.timeIntervalSince(context.lastAccessedAt) > configuration.sessionTimeout else { + package func sessionIsClosingForTesting(sessionID: String) -> Bool { + guard let context = sessions[sessionID], case .closing = context.phase else { + return false + } + return true + } + + package func registerSessionMemberForTesting( + _ runID: ReviewRunID, + sessionID: String + ) async throws { + try await sessionRegistry.registerMemberForTesting(runID, in: sessionID) + } + + package func resourceSnapshotForTesting() async -> ResourceSnapshot { + let registrySessionCount = await sessionRegistry.sessionCountForTesting() + return ResourceSnapshot( + listenerCount: channel == nil ? 0 : 1, + eventLoopGroupCount: eventLoopGroup == nil ? 0 : 1, + sessionCount: sessions.count, + registrySessionCount: registrySessionCount, + cleanupTaskCount: cleanupTask == nil ? 0 : 1, + requestPumpTaskCount: requestPumpTask == nil ? 0 : 1, + activeRequestWorkCount: activeRequestWorkCount, + childChannelCount: childChannels.count + ) + } + + private func closeExpiredSessions(now: MCPHTTPServerClock.Instant) async { + var closeCompletions: [Task] = [] + let sessionSnapshot = Array(sessions) + for (sessionID, context) in sessionSnapshot { + guard case .open = context.phase else { continue } if context.activeRequestCount > 0 { + updateSession(sessionID) { context in + context.idleSince = nil + } continue } - if await adapter.hasActiveReviews(in: sessionID) { - if var session = sessions[sessionID] { - session.lastAccessedAt = Date() - sessions[sessionID] = session + let hasPendingRegistryWork = await sessionRegistry.hasPendingWork(in: sessionID) + let hasActiveReviews = await adapter.hasActiveReviews(in: sessionID) + if hasPendingRegistryWork || hasActiveReviews { + updateSession(sessionID) { context in + context.idleSince = nil } continue } - if let current = sessions[sessionID], - current.activeRequestCount == 0, - now.timeIntervalSince(current.lastAccessedAt) > configuration.sessionTimeout - { - expiredSessionIDs.append(sessionID) + guard let currentContext = sessions[sessionID], + case .open = currentContext.phase, + currentContext.activeRequestCount == 0, + currentContext.idleSince == context.idleSince else { + continue + } + guard let idleSince = currentContext.idleSince else { + updateSession(sessionID) { context in + context.idleSince = now + } + continue + } + if idleSince.duration(to: now) > configuration.sessionTimeout { + if let completion = beginCloseSession(sessionID, reason: .timeout) { + closeCompletions.append(completion) + } } } - for sessionID in expiredSessionIDs { - await closeSession(sessionID) + for completion in closeCompletions { + await completion.value } } @@ -516,7 +1034,121 @@ package actor CodexReviewMCPHTTPServer { } } -private final class ActiveRequestCompletion: @unchecked Sendable { +private struct MCPHTTPRequestWork: Sendable { + let run: @Sendable () async -> Void +} + +private final class MCPHTTPRequestEventSink: Sendable { + private struct State { + var isOpen = true + let continuation: AsyncStream.Continuation + } + + let events: AsyncStream + private let state: Mutex + + init() { + let (events, continuation) = AsyncStream.makeStream() + self.events = events + self.state = Mutex(State(continuation: continuation)) + } + + @discardableResult + func send(_ work: MCPHTTPRequestWork) -> Bool { + state.withLock { state in + guard state.isOpen else { + return false + } + state.continuation.yield(work) + return true + } + } + + func finish() { + state.withLock { state in + guard state.isOpen else { + return + } + state.isOpen = false + state.continuation.finish() + } + } +} + +private final class MCPHTTPChannelCancellation: Sendable { + private struct State { + var isCancelled = false + var waiters: [UUID: CheckedContinuation] = [:] + } + + private let state = Mutex(State()) + + func wait() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isCancelled || Task.isCancelled { + return true + } + state.waiters[waiterID] = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } onCancel: { [self] in + let continuation = state.withLock { state in + state.waiters.removeValue(forKey: waiterID) + } + continuation?.resume() + } + } + + func cancel() { + let waiters = state.withLock { state in + guard state.isCancelled == false else { + return [CheckedContinuation]() + } + state.isCancelled = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll() + return waiters + } + for waiter in waiters { + waiter.resume() + } + } +} + +private final class MCPHTTPChannelRegistry: Sendable { + private let channels = Mutex<[ObjectIdentifier: any Channel]>([:]) + + func insert(_ channel: any Channel) { + channels.withLock { channels in + channels[ObjectIdentifier(channel)] = channel + } + } + + func remove(_ channel: any Channel) { + channels.withLock { channels in + _ = channels.removeValue(forKey: ObjectIdentifier(channel)) + } + } + + func snapshot() -> [any Channel] { + channels.withLock { channels in + Array(channels.values) + } + } + + var count: Int { + channels.withLock { $0.count } + } +} + +private final class ActiveRequestCompletion: Sendable { private let didFinish = Mutex(false) private let onFinish: @Sendable () -> Void @@ -560,6 +1192,20 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked buffer.writeBytes(data) context.writeAndFlush(handler.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: promise) } + + func writeResponse( + head: HTTPResponseHead, + body: Data?, + promise: EventLoopPromise + ) { + context.write(handler.wrapOutboundOut(.head(head)), promise: nil) + if let body { + var buffer = context.channel.allocator.buffer(capacity: body.count) + buffer.writeBytes(body) + context.write(handler.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + } + context.writeAndFlush(handler.wrapOutboundOut(.end(nil)), promise: promise) + } } private struct RequestState { @@ -567,14 +1213,30 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked var bodyBuffer: ByteBuffer } + private enum StreamWriterOutcome: Sendable { + case completed + case channelClosed + case stopped + case failed(String) + } + private let server: CodexReviewMCPHTTPServer + private let endpoint: String + private let heartbeatInterval: Duration? + private let eventSink: MCPHTTPRequestEventSink + private let channelCancellation = MCPHTTPChannelCancellation() private var requestState: RequestState? - private var activeStreamTask: Task? - private var activeStreamID: UUID? - private var activeStreamCompletion: ActiveRequestCompletion? - init(server: CodexReviewMCPHTTPServer) { + init( + server: CodexReviewMCPHTTPServer, + endpoint: String, + heartbeatInterval: Duration?, + eventSink: MCPHTTPRequestEventSink + ) { self.server = server + self.endpoint = endpoint + self.heartbeatInterval = heartbeatInterval + self.eventSink = eventSink } func channelRead(context: ChannelHandlerContext, data: NIOAny) { @@ -593,8 +1255,11 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked } requestState = nil nonisolated(unsafe) let context = context - Task { + let didSubmit = eventSink.send(MCPHTTPRequestWork { [self] in await handleRequest(state: state, context: context) + }) + if didSubmit == false { + context.close(promise: nil) } } } @@ -605,13 +1270,13 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked } func channelInactive(context: ChannelHandlerContext) { - finishActiveStream() + channelCancellation.cancel() context.fireChannelInactive() } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { if case ChannelEvent.inputClosed = event { - finishActiveStream() + channelCancellation.cancel() context.close(promise: nil) return } @@ -619,25 +1284,16 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked } func errorCaught(context: ChannelHandlerContext, error: any Error) { - finishActiveStream() + channelCancellation.cancel() context.close(promise: nil) } - private func finishActiveStream() { - activeStreamTask?.cancel() - activeStreamCompletion?.finish() - activeStreamTask = nil - activeStreamID = nil - activeStreamCompletion = nil - } - private func handleRequest( state: RequestState, context: ChannelHandlerContext ) async { let head = state.head let path = head.uri.split(separator: "?").first.map(String.init) ?? head.uri - let endpoint = await server.endpoint guard path == endpoint else { await writeResponse( .init(response: .error(statusCode: 404, .invalidRequest("Not Found"))), @@ -693,77 +1349,105 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked switch response { case .stream(let stream, _): - let streamID = UUID() - let streamTask = Task { - var head = HTTPResponseHead(version: version, status: status) - for (name, value) in headers { - head.headers.add(name: name, value: value) - } + defer { + trackedResponse.streamCompletion?.finish() + } + let responseHead = makeResponseHead( + version: version, + status: status, + headers: headers + ) + eventLoop.execute { + context.read() + } - var iterator = stream.makeAsyncIterator() - do { - try Task.checkCancellation() - try await writeResponsePart(.head(head), context: context, eventLoop: eventLoop) - while let chunk = try await iterator.next() { + let outcome = await withTaskGroup( + of: StreamWriterOutcome.self, + returning: StreamWriterOutcome.self + ) { group in + group.addTask { [self] in + do { try Task.checkCancellation() - try await writeResponseBody(chunk, context: context, eventLoop: eventLoop) + try await writeResponsePart( + .head(responseHead), + context: context, + eventLoop: eventLoop + ) + for try await chunk in stream { + try Task.checkCancellation() + try await writeResponseBody(chunk, context: context, eventLoop: eventLoop) + } + return .completed + } catch is CancellationError { + return .stopped + } catch { + return .failed(error.localizedDescription) } - } catch is CancellationError { - trackedResponse.streamCompletion?.finish() - return - } catch { - trackedResponse.streamCompletion?.finish() - logger.error("MCP SSE stream failed: \(error.localizedDescription, privacy: .public)") } - - guard Task.isCancelled == false else { - return - } - try? await writeResponsePart(.end(nil), context: context, eventLoop: eventLoop) - } - eventLoop.execute { - context.channel.closeFuture.whenComplete { _ in - trackedResponse.streamCompletion?.finish() - streamTask.cancel() + group.addTask { [channelCancellation] in + await channelCancellation.wait() + return Task.isCancelled ? .stopped : .channelClosed } - guard context.channel.isActive else { - trackedResponse.streamCompletion?.finish() - streamTask.cancel() - return + if let heartbeatInterval { + group.addTask { [self] in + do { + while Task.isCancelled == false { + try await Task.sleep(for: heartbeatInterval) + try Task.checkCancellation() + try await writeResponseBody( + Data(": keep-alive\n\n".utf8), + context: context, + eventLoop: eventLoop + ) + } + return .stopped + } catch is CancellationError { + return .stopped + } catch { + return .failed(error.localizedDescription) + } + } } - self.activeStreamTask?.cancel() - self.activeStreamCompletion?.finish() - self.activeStreamTask = streamTask - self.activeStreamID = streamID - self.activeStreamCompletion = trackedResponse.streamCompletion - context.read() + + let first = await group.next() ?? .stopped + group.cancelAll() + while await group.next() != nil {} + return first } - await streamTask.value - eventLoop.execute { - if self.activeStreamID == streamID { - self.activeStreamTask = nil - self.activeStreamID = nil - self.activeStreamCompletion = nil - } + + switch outcome { + case .completed: + try? await writeResponsePart(.end(nil), context: context, eventLoop: eventLoop) + case .failed(let message): + logger.error("MCP SSE stream failed: \(message, privacy: .public)") + case .channelClosed, .stopped: + break } default: let body = response.bodyData + var head = makeResponseHead( + version: version, + status: status, + headers: headers + ) + if let body { + head.headers.add(name: "Content-Length", value: "\(body.count)") + } + let responseHead = head + let writer = ResponsePartWriter(handler: self, context: context) + let promise = eventLoop.makePromise(of: Void.self) eventLoop.execute { - var head = HTTPResponseHead(version: version, status: status) - for (name, value) in headers { - head.headers.add(name: name, value: value) - } - if let body { - head.headers.add(name: "Content-Length", value: "\(body.count)") - } - context.write(self.wrapOutboundOut(.head(head)), promise: nil) - if let body { - var buffer = context.channel.allocator.buffer(capacity: body.count) - buffer.writeBytes(body) - context.write(self.wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) - } - context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + writer.writeResponse( + head: responseHead, + body: body, + promise: promise + ) + } + do { + try await promise.futureResult.get() + } catch { + logger.error("MCP HTTP response write failed: \(error.localizedDescription, privacy: .public)") } } } @@ -781,6 +1465,18 @@ private final class CodexReviewMCPHTTPHandler: ChannelInboundHandler, @unchecked try await promise.futureResult.get() } + private func makeResponseHead( + version: HTTPVersion, + status: HTTPResponseStatus, + headers: [String: String] + ) -> HTTPResponseHead { + var head = HTTPResponseHead(version: version, status: status) + for (name, value) in headers { + head.headers.add(name: name, value: value) + } + return head + } + private func writeResponseBody( _ data: Data, context: ChannelHandlerContext, diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift index 14d5e61a..1c6fe422 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPProtocolServer.swift @@ -32,7 +32,8 @@ package actor MCPClientSessionState { @MainActor package func makeMCPProtocolServer( adapter: CodexReviewMCPServer, - defaultSessionID: String? = nil, + sessionID: String, + sessionRegistry: MCPReviewSessionRegistry, clientSession: MCPClientSessionState = .init(), boundedReviewWaitDuration: Duration = .seconds(540) ) async -> Server { @@ -46,57 +47,150 @@ package func makeMCPProtocolServer( ) await server.withMethodHandler(ListTools.self) { _ in - let tools = await adapter.tools.map { descriptor in - Tool( - name: descriptor.name.rawValue, - description: descriptor.description, - inputSchema: schema(for: descriptor.name) - ) + try await withMCPSessionOperation(registry: sessionRegistry, sessionID: sessionID) { _ in + let tools = await adapter.tools.map { descriptor in + Tool( + name: descriptor.name.rawValue, + description: descriptor.description, + inputSchema: schema(for: descriptor.name) + ) + } + return .init(tools: tools) } - return .init(tools: tools) } await server.withMethodHandler(CallTool.self) { params in - guard let tool = CodexReviewMCP.Tool.Name(rawValue: params.name) else { - return .init( - content: [.text(text: "Unknown tool: \(params.name)", annotations: nil, _meta: nil)], - isError: true - ) - } + await withMCPSessionOperationResult( + registry: sessionRegistry, + sessionID: sessionID + ) { operation in + guard let tool = CodexReviewMCP.Tool.Name(rawValue: params.name) else { + return .init( + content: [.text(text: "Unknown tool: \(params.name)", annotations: nil, _meta: nil)], + isError: true + ) + } - do { let httpContext = Server.currentHandlerContext?.httpContext let useBoundedReviewStart = await clientSession.usesBoundedReviewStart(httpContext: httpContext) let request = try toolRequest( tool: tool, arguments: params.arguments ?? [:], - defaultSessionID: defaultSessionID, + defaultSessionID: sessionID, boundedReviewWaitDuration: boundedReviewWaitDuration, useBoundedReviewStart: useBoundedReviewStart ) - let response = try await adapter.handle(request) + let response: CodexReviewMCP.Tool.Response + if case .reviewStart = request { + response = try await handleReviewStart( + request, + sessionID: sessionID, + registry: sessionRegistry, + adapter: adapter + ) + } else { + let allowedRunIDs = try await sessionRegistry.members(for: operation) + response = try await adapter.handle( + request, + allowedRunIDs: allowedRunIDs + ) + } return try toolResult(response: response) - } catch { - return .init( - content: [.text(text: error.localizedDescription, annotations: nil, _meta: nil)], - isError: true - ) } } await server.withMethodHandler(ListResources.self) { _ in - .init(resources: helpResources.map(\.resource)) + try await withMCPSessionOperation(registry: sessionRegistry, sessionID: sessionID) { _ in + .init(resources: helpResources.map(\.resource)) + } } await server.withMethodHandler(ReadResource.self) { params in - let content = helpResources.first { $0.uri == params.uri }?.content - ?? "Resource not found: \(params.uri)" - return .init(contents: [.text(content, uri: params.uri, mimeType: "text/markdown")]) + try await withMCPSessionOperation(registry: sessionRegistry, sessionID: sessionID) { _ in + let content = helpResources.first { $0.uri == params.uri }?.content + ?? "Resource not found: \(params.uri)" + return .init(contents: [.text(content, uri: params.uri, mimeType: "text/markdown")]) + } } await server.withMethodHandler(ListResourceTemplates.self) { _ in - .init(templates: helpResourceTemplates) + try await withMCPSessionOperation(registry: sessionRegistry, sessionID: sessionID) { _ in + .init(templates: helpResourceTemplates) + } } return server } + +private func withMCPSessionOperation( + registry: MCPReviewSessionRegistry, + sessionID: String, + operation: @Sendable (MCPSessionOperationToken) async throws -> Result +) async throws -> Result { + let token = try await registry.beginOperation(in: sessionID) + let result: Result + do { + result = try await operation(token) + } catch { + try await registry.finishOperation(token) + throw error + } + try await registry.finishOperation(token) + return result +} + +private func withMCPSessionOperationResult( + registry: MCPReviewSessionRegistry, + sessionID: String, + operation: @Sendable (MCPSessionOperationToken) async throws -> CallTool.Result +) async -> CallTool.Result { + do { + return try await withMCPSessionOperation( + registry: registry, + sessionID: sessionID, + operation: operation + ) + } catch { + return .init( + content: [.text(text: error.localizedDescription, annotations: nil, _meta: nil)], + isError: true + ) + } +} + +private func handleReviewStart( + _ request: CodexReviewMCP.Tool.Request, + sessionID: String, + registry: MCPReviewSessionRegistry, + adapter: CodexReviewMCPServer +) async throws -> CodexReviewMCP.Tool.Response { + guard case .reviewStart(_, let reviewRequest, let waitTimeout) = request else { + preconditionFailure("Only review_start can enter the start reservation path.") + } + let reservation = try await registry.reserveStart(in: sessionID) + var reservationIsPending = true + do { + let runID = try await adapter.beginReview( + sessionID: sessionID, + request: reviewRequest + ) + let disposition = try await registry.bind( + runID: runID, + reservation: reservation + ) + reservationIsPending = false + guard case .bound = disposition else { + throw MCPReviewSessionRegistryError.sessionNotOpen(sessionID) + } + return try await adapter.finishReviewStart( + sessionID: sessionID, + runID: runID, + waitTimeout: waitTimeout + ) + } catch { + if reservationIsPending { + try await registry.finishStart(reservation) + } + throw error + } +} diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 3b4b725a..f0cbd3fc 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -1,5 +1,6 @@ import Foundation -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexReviewKit package enum CodexReviewMCP { @@ -8,7 +9,7 @@ package enum CodexReviewMCP { package typealias ReviewMCPLogProjectionProvider = @MainActor @Sendable ( CodexReviewAPI.Read.Result -) async -> ReviewMCPLogProjection? +) async throws -> ReviewChatProjectionLookup package extension CodexReviewMCP.Tool { enum Name: String, Codable, Equatable, Sendable, CaseIterable { @@ -35,8 +36,8 @@ package extension CodexReviewMCP.Tool { package extension CodexReviewMCP.Tool { enum Request: Equatable, Sendable { case reviewStart(sessionID: String, request: CodexReviewAPI.Start.Request, waitTimeout: Duration?) - case reviewAwait(sessionID: String?, runID: String, waitTimeout: Duration) - case reviewRead(sessionID: String?, runID: String) + case reviewAwait(sessionID: String?, runID: ReviewRunID, waitTimeout: Duration) + case reviewRead(sessionID: String?, runID: ReviewRunID) case reviewList(sessionID: String?, cwd: String?, statuses: [ReviewRunState]?, limit: Int?) case reviewCancel(sessionID: String?, selector: CodexReviewAPI.Run.Selector, reason: ReviewCancellation) } @@ -52,6 +53,40 @@ package extension CodexReviewMCP.Tool { self.log = log } } + + internal struct ReviewListItemSnapshot: Equatable, Sendable { + var result: CodexReviewAPI.Run.ListItem + var log: ReviewMCPLogProjection + + init( + result: CodexReviewAPI.Run.ListItem, + log: ReviewMCPLogProjection + ) { + self.result = result + self.log = log + } + } + + internal struct ReviewListSnapshot: Equatable, Sendable { + var items: [ReviewListItemSnapshot] + + init(items: [ReviewListItemSnapshot]) { + self.items = items + } + } + + internal struct ReviewCancelSnapshot: Equatable, Sendable { + var result: CodexReviewAPI.Cancel.Outcome + var log: ReviewMCPLogProjection + + init( + result: CodexReviewAPI.Cancel.Outcome, + log: ReviewMCPLogProjection + ) { + self.result = result + self.log = log + } + } } package extension CodexReviewMCP.Tool { @@ -59,8 +94,8 @@ package extension CodexReviewMCP.Tool { case reviewStart(ReviewSnapshot) case reviewAwait(ReviewSnapshot) case reviewRead(ReviewSnapshot) - case reviewList(CodexReviewAPI.List.Result) - case reviewCancel(CodexReviewAPI.Cancel.Outcome) + case reviewList(ReviewListSnapshot) + case reviewCancel(ReviewCancelSnapshot) } } @@ -87,25 +122,23 @@ package final class CodexReviewMCPServer { ] } - func handle(_ request: CodexReviewMCP.Tool.Request) async throws -> CodexReviewMCP.Tool.Response { + func handle( + _ request: CodexReviewMCP.Tool.Request, + allowedRunIDs: Set? = nil + ) async throws -> CodexReviewMCP.Tool.Response { switch request { case .reviewStart(let sessionID, let reviewRequest, let waitTimeout): - let result: CodexReviewAPI.Read.Result - if let waitTimeout { - result = try await store.startReview( - sessionID: sessionID, - request: reviewRequest, - waitTimeout: waitTimeout - ) - } else { - result = try await store.startReview(sessionID: sessionID, request: reviewRequest) - } - let snapshot = try await reviewSnapshot( - result, - sessionID: sessionID + let runID = try await beginReview( + sessionID: sessionID, + request: reviewRequest + ) + return try await finishReviewStart( + sessionID: sessionID, + runID: runID, + waitTimeout: waitTimeout ) - return .reviewStart(snapshot) case .reviewAwait(let sessionID, let runID, let waitTimeout): + try requireAllowed(runID, allowedRunIDs: allowedRunIDs) let snapshot = try await reviewSnapshot( try await store.awaitReview( sessionID: sessionID, @@ -116,6 +149,7 @@ package final class CodexReviewMCPServer { ) return .reviewAwait(snapshot) case .reviewRead(let sessionID, let runID): + try requireAllowed(runID, allowedRunIDs: allowedRunIDs) let snapshot = try await reviewSnapshot( try store.readReview( sessionID: sessionID, @@ -125,21 +159,57 @@ package final class CodexReviewMCPServer { ) return .reviewRead(snapshot) case .reviewList(let sessionID, let cwd, let statuses, let limit): - return .reviewList(store.listReviews( + let result = store.listReviews( sessionID: sessionID, cwd: cwd, statuses: statuses, - limit: limit - )) + limit: limit, + allowedRunIDs: allowedRunIDs + ) + return .reviewList(try await reviewListSnapshot(result)) case .reviewCancel(let sessionID, let selector, let reason): let runRecord = try store.resolveRun( sessionID: sessionID, - selector: selector.defaultingToActiveStatusesForCancellation() + selector: selector.defaultingToActiveStatusesForCancellation(), + allowedRunIDs: allowedRunIDs ) - return .reviewCancel(try await store.cancelReview( + let result = try await store.cancelReview( runID: runRecord.id, cancellation: reason - )) + ) + return .reviewCancel(try await reviewCancelSnapshot(result)) + } + } + + func beginReview( + sessionID: String, + request: CodexReviewAPI.Start.Request + ) async throws -> ReviewRunID { + try await store.beginReview(sessionID: sessionID, request: request) + } + + func finishReviewStart( + sessionID: String, + runID: ReviewRunID, + waitTimeout: Duration? + ) async throws -> CodexReviewMCP.Tool.Response { + let result = try await store.awaitReview( + sessionID: sessionID, + runID: runID, + timeout: waitTimeout + ) + return .reviewStart(try await reviewSnapshot(result, sessionID: sessionID)) + } + + private func requireAllowed( + _ runID: ReviewRunID, + allowedRunIDs: Set? + ) throws { + guard let allowedRunIDs else { + return + } + guard allowedRunIDs.contains(runID) else { + throw MCPReviewSessionRegistryError.runNotFound(runID) } } @@ -150,12 +220,95 @@ package final class CodexReviewMCPServer { if let sessionID { _ = try store.resolveRun(sessionID: sessionID, selector: .init(runID: result.runID)) } - let log = await logProjectionProvider?(result) ?? ReviewMCPLogProjection.unavailable(result: result) + let lookup = try await logProjectionProvider?(result) ?? .unavailable + let log = try resolvedLogProjection(lookup, for: result) return .init(result: result, log: log) } - package func closeSession(_ sessionID: String) async { - await store.closeSession(sessionID) + private func reviewListSnapshot( + _ result: CodexReviewAPI.List.Result + ) async throws -> CodexReviewMCP.Tool.ReviewListSnapshot { + var items: [CodexReviewMCP.Tool.ReviewListItemSnapshot] = [] + items.reserveCapacity(result.items.count) + for item in result.items { + let readResult = CodexReviewAPI.Read.Result( + runID: item.runID, + core: item.core, + presentation: item.presentation, + elapsedSeconds: item.elapsedSeconds + ) + items.append(.init( + result: item, + log: try await terminalReviewProjection(for: readResult) + )) + } + return .init(items: items) + } + + private func reviewCancelSnapshot( + _ result: CodexReviewAPI.Cancel.Outcome + ) async throws -> CodexReviewMCP.Tool.ReviewCancelSnapshot { + let readResult = CodexReviewAPI.Read.Result( + runID: result.runID, + core: result.core, + presentation: result.presentation + ) + return .init( + result: result, + log: try await terminalReviewProjection(for: readResult) + ) + } + + private func terminalReviewProjection( + for result: CodexReviewAPI.Read.Result + ) async throws -> ReviewMCPLogProjection { + guard result.presentation.status == .succeeded else { + return .unavailable(result: result) + } + let lookup = try await logProjectionProvider?(result) ?? .unavailable + return try resolvedLogProjection(lookup, for: result) + } + + private func resolvedLogProjection( + _ lookup: ReviewChatProjectionLookup, + for result: CodexReviewAPI.Read.Result + ) throws -> ReviewMCPLogProjection { + switch lookup { + case .available(let projection): + guard let attempt = result.core.attempt, + projection.turnID?.rawValue == attempt.turnID.rawValue else { + throw ReviewMCPError.projectionInvariantViolation(runID: result.runID) + } + if result.presentation.status == .succeeded, + projection.finalResult?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + throw ReviewMCPError.projectionInvariantViolation(runID: result.runID) + } + return projection + case .unavailable: + guard result.presentation.status != .succeeded else { + throw ReviewMCPError.projectionInvariantViolation(runID: result.runID) + } + return .unavailable(result: result) + case .refreshFailed(let failure): + throw ReviewMCPError.projectionRefreshFailed( + runID: result.runID, + failure: failure + ) + } + } + + package func closeSession( + _ sessionID: String + ) async -> MCPReviewSessionStoreCloseResult { + let result = await store.closeSession(sessionID) + return .init( + terminalAndDrainedRunIDs: result.terminalAndDrainedRunIDs, + failedRunIDs: result.failedRunIDs + ) + } + + package func releaseClosedSession(_ sessionID: String) { + store.releaseClosedSession(sessionID) } package func hasActiveReviews(in sessionID: String) -> Bool { @@ -168,39 +321,39 @@ package extension CodexReviewMCPServer { modelContext: CodexModelContext ) -> ReviewMCPLogProjectionProvider { { result in - guard let turnID = result.core.run.turnID?.nilIfEmpty else { - return nil - } - guard let threadID = (result.core.run.reviewThreadID ?? result.core.run.threadID)?.nilIfEmpty else { - return nil + guard let attempt = result.core.attempt else { + return .unavailable } + let turnID = attempt.turnID.rawValue + let threadID = attempt.threadIdentity.activeTurnThreadID.rawValue let chat = modelContext.model(for: CodexThreadID(rawValue: threadID)) do { try await modelContext.refresh(chat, includeTurns: true) + } catch is CancellationError { + throw CancellationError() + } catch let failure as CodexFetchFailure { + return .refreshFailed(failure) + } catch let failure as CodexAppServerError { + return .refreshFailed(.appServer(failure)) } catch { - return nil + preconditionFailure("CodexDataKit refresh threw an unsupported error: \(error)") } let codexTurnID = CodexTurnID(rawValue: turnID) guard chat.turn(id: codexTurnID) != nil else { - return nil + return .unavailable } - return ReviewMCPLogProjection( + let transcript = chat.transcript(in: codexTurnID) + return .available(ReviewMCPLogProjection( result: result, turnID: codexTurnID, - threadItems: chat.items(in: codexTurnID).map(\.threadItemForReviewMCP) - ) + threadItems: transcript.items, + reviewOutputText: transcript.reviewOutputText + )) } } } -@MainActor -private extension CodexItem { - var threadItemForReviewMCP: CodexThreadItem { - CodexThreadItem(id: itemID, kind: kind, content: content, rawPayload: rawPayload) - } -} - private extension CodexReviewAPI.Run.Selector { func defaultingToActiveStatusesForCancellation() -> CodexReviewAPI.Run.Selector { guard runID == nil, statuses == nil else { diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift index 32e991ad..23ce60be 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolRequests.swift @@ -14,31 +14,31 @@ func toolRequest( let cwd = try requiredString("cwd", in: arguments) let target = try reviewTarget(from: requiredObject("target", in: arguments)) return .reviewStart( - sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID) ?? "default", + sessionID: try sessionID(in: arguments, defaultSessionID: defaultSessionID) ?? "default", request: .init(cwd: cwd, target: target), waitTimeout: useBoundedReviewStart ? boundedReviewWaitDuration : nil ) case .reviewAwait: return .reviewAwait( - sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID), + sessionID: try sessionID(in: arguments, defaultSessionID: defaultSessionID), runID: try requiredRunID(in: arguments), waitTimeout: boundedReviewWaitDuration ) case .reviewRead: return .reviewRead( - sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID), + sessionID: try sessionID(in: arguments, defaultSessionID: defaultSessionID), runID: try requiredRunID(in: arguments) ) case .reviewList: return .reviewList( - sessionID: sessionID(in: arguments, defaultSessionID: defaultSessionID), + sessionID: try sessionID(in: arguments, defaultSessionID: defaultSessionID), cwd: arguments["cwd"]?.stringValue, statuses: try statuses(from: arguments["statuses"]), limit: arguments["limit"]?.intValue ) case .reviewCancel: let runID = try ReviewRunIDArgument.optionalValue(in: arguments) - let sessionID = sessionID( + let sessionID = try sessionID( in: arguments, defaultSessionID: defaultSessionID, fallback: runID == nil ? "default" : nil @@ -59,11 +59,27 @@ func sessionID( in arguments: [String: Value], defaultSessionID: String?, fallback: String? = nil -) -> String? { - defaultSessionID ?? arguments["sessionID"]?.stringValue ?? fallback +) throws -> String? { + let suppliedSessionID: String? + if let suppliedValue = arguments["sessionID"] { + guard let stringValue = suppliedValue.stringValue else { + throw MCPProtocolServerError.invalidArgument("sessionID must be a string.") + } + suppliedSessionID = stringValue + } else { + suppliedSessionID = nil + } + if let defaultSessionID, let suppliedSessionID { + guard suppliedSessionID == defaultSessionID else { + throw MCPProtocolServerError.invalidArgument( + "sessionID must match the active MCP transport session." + ) + } + } + return defaultSessionID ?? suppliedSessionID ?? fallback } -func requiredRunID(in arguments: [String: Value]) throws -> String { +func requiredRunID(in arguments: [String: Value]) throws -> ReviewRunID { try ReviewRunIDArgument.requiredValue(in: arguments) } diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 515cb0d4..945dd26e 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -11,11 +11,11 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul .reviewAwait(let snapshot): value = snapshot.result.structuredContentForStartOrAwait(log: snapshot.log) text = snapshot.result.textContentForStartOrAwait(log: snapshot.log) - isError = snapshot.result.core.lifecycle.status == .failed + isError = snapshot.result.presentation.status == .failed case .reviewRead(let snapshot): value = snapshot.result.structuredContentForRead(log: snapshot.log) text = snapshot.result.textContentForRead(log: snapshot.log) - isError = snapshot.result.core.lifecycle.status == .failed + isError = snapshot.result.presentation.status == .failed case .reviewList(let result): value = result.structuredContent() text = "Listed \(result.items.count) review run(s)." @@ -34,27 +34,27 @@ func toolResult(response: CodexReviewMCP.Tool.Response) throws -> CallTool.Resul private extension CodexReviewAPI.Read.Result { func textContent(log: ReviewMCPLogProjection) -> String { - log.finalResult?.nilIfEmpty ?? core.finalReview ?? core.displayLifecycleMessage + log.finalResult?.nilIfEmpty ?? presentation.lifecycle.message } func textContentForStartOrAwait(log: ReviewMCPLogProjection) -> String { - if core.lifecycle.status.isTerminal { + if presentation.status.isTerminal { return textContent(log: log) } - var status = "Review \(core.lifecycle.status.rawValue)" + var status = "Review \(presentation.status.rawValue)" if let elapsedSeconds { status += " for \(elapsedSeconds)s" } - return "\(status). runId: \(runID). Call `review_await` with this runId to continue waiting." + return "\(status). runId: \(runID.rawValue). Call `review_await` with this runId to continue waiting." } func textContentForRead(log: ReviewMCPLogProjection) -> String { - if core.lifecycle.status.isTerminal { + if presentation.status.isTerminal { return textContent(log: log) } - var status = "Review \(core.lifecycle.status.rawValue)" + var status = "Review \(presentation.status.rawValue)" if let elapsedSeconds { status += " for \(elapsedSeconds)s" } @@ -63,49 +63,44 @@ private extension CodexReviewAPI.Read.Result { func structuredContentForStartOrAwait(log: ReviewMCPLogProjection) -> Value { structuredContent( - includeLog: true, - includeDetails: false, - includeNextAction: core.lifecycle.status.isTerminal == false, + includeDetailedLog: false, + includeNextAction: presentation.status.isTerminal == false, log: log ) } func structuredContentForRead(log: ReviewMCPLogProjection) -> Value { structuredContent( - includeLog: true, - includeDetails: true, + includeDetailedLog: true, includeNextAction: false, log: log ) } func structuredContent( - includeLog: Bool, - includeDetails: Bool, + includeDetailedLog: Bool, includeNextAction: Bool, log: ReviewMCPLogProjection ) -> Value { var object: [String: Value] = [ - "runId": .string(runID), - "run": core.run.structuredContent(), - "lifecycle": core.structuredLifecycleContent( + "runId": .string(runID.rawValue), + "run": core.structuredRunContent(), + "lifecycle": presentation.structuredLifecycleContent( + core: core, elapsedSeconds: elapsedSeconds, - cancellable: cancellable + cancellable: presentation.isCancellable ), "review": core.structuredReviewContent( - resolvedFinalReview: log.finalResult?.nilIfEmpty ?? core.finalReview + resolvedFinalReview: log.finalResult ), ] - if includeLog { - object["log"] = - includeDetails - ? log.structuredContentWithItems() - : log.structuredContent() + if includeDetailedLog { + object["log"] = log.structuredContentWithItems() } if includeNextAction { object["nextAction"] = .object([ "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue), - "runId": .string(runID), + "runId": .string(runID.rawValue), ]) } return .object(object) @@ -113,52 +108,15 @@ private extension CodexReviewAPI.Read.Result { } private extension ReviewMCPLogProjection { - func structuredContent() -> Value { - var truncatedFields: [String] = [] - let orderedEntryIDs = Array(self.orderedEntryIDs.suffix(Self.compactEntryIDLimit)) - let activeEntryIDs = Array(self.activeEntryIDs.suffix(Self.compactEntryIDLimit)) - if orderedEntryIDs.count < self.orderedEntryIDs.count { - truncatedFields.append("orderedEntryIds") - } - if activeEntryIDs.count < self.activeEntryIDs.count { - truncatedFields.append("activeEntryIds") - } - var object: [String: Value] = [ - "revision": .string(revision), - "orderedEntryIds": .array(orderedEntryIDs.map(Value.string)), - "activeEntryIds": .array(activeEntryIDs.map(Value.string)), - "activeEntryCount": .int(activeEntryCount), - "latestEntryId": latestEntryID.map(Value.string) ?? .null, - "finalLifecycleMessage": boundedLogString( - finalLifecycleMessage, - field: "finalLifecycleMessage", - truncatedFields: &truncatedFields - ), - "finalResult": boundedLogString( - finalResult, - field: "finalResult", - truncatedFields: &truncatedFields - ), - ] - let page = LogEntryPage.unreturned(total: items.count) - object["items"] = .array([]) - object["itemsPage"] = page.structuredContent() - object["truncatedFields"] = .array(truncatedFields.map(Value.string)) - return .object(object) - } - func structuredContentWithItems() -> Value { // Long reviews can accumulate huge transcripts; detailed reads return // a bounded tail page so MCP responses stay small, with the page - // metadata pointing at the omitted head. The entry id arrays are - // bounded to the same window so no field scales with the full - // transcript. + // metadata pointing at the omitted head. let limit = Self.detailedItemsLimit let total = items.count let pageItems = Array(items.suffix(limit)) let returned = pageItems.count let offset = total - returned - let pageItemIDs = Set(pageItems.map(\.id)) let page = LogEntryPage( total: total, offset: offset, @@ -169,41 +127,18 @@ private extension ReviewMCPLogProjection { previousOffset: offset > 0 ? max(0, offset - limit) : nil, nextOffset: nil ) - var truncatedFields: [String] = [] - var object: [String: Value] = [ - "revision": .string(revision), - "orderedEntryIds": .array( - orderedEntryIDs.filter(pageItemIDs.contains).map(Value.string) - ), - "activeEntryIds": .array( - activeEntryIDs.filter(pageItemIDs.contains).map(Value.string) - ), - "activeEntryCount": .int(activeEntryCount), - "latestEntryId": latestEntryID.map(Value.string) ?? .null, - "finalLifecycleMessage": boundedLogString( - finalLifecycleMessage, - field: "finalLifecycleMessage", - truncatedFields: &truncatedFields - ), - "finalResult": boundedLogString( - finalResult, - field: "finalResult", - truncatedFields: &truncatedFields - ), - ] - object["items"] = .array(pageItems.map { $0.structuredContent() }) - object["itemsPage"] = page.structuredContent() - object["truncatedFields"] = .array(truncatedFields.map(Value.string)) - return .object(object) + return .object([ + "items": .array(pageItems.map { $0.structuredContent() }), + "itemsPage": page.structuredContent(), + ]) } - private static var compactEntryIDLimit: Int { 100 } private static var detailedItemsLimit: Int { 100 } } private extension ReviewMCPLogProjection.Item { func structuredContent() -> Value { - .object([ + return .object([ "id": .string(id), "kind": .string(kind), "content": content.structuredContent(), @@ -261,10 +196,6 @@ private struct LogEntryPage { var previousOffset: Int? var nextOffset: Int? - var range: Range { - offset.. LogEntryPage { - LogEntryPage( - total: total, - offset: 0, - limit: 0, - returned: 0, - hasMoreBefore: false, - hasMoreAfter: total > 0, - previousOffset: nil, - nextOffset: total > 0 ? 0 : nil - ) - } - func structuredContent() -> Value { .object([ "total": .int(total), @@ -312,17 +230,6 @@ private struct LogEntryPage { } } -private func boundedLogString( - _ value: String?, - field: String, - truncatedFields: inout [String] -) -> Value { - guard let value else { - return .null - } - return boundedLogString(value, field: field, truncatedFields: &truncatedFields) -} - private func boundedLogString( _ value: String, field: String, @@ -346,23 +253,26 @@ private extension String { } } -private extension CodexReviewAPI.Run.ListItem { +private extension CodexReviewMCP.Tool.ReviewListItemSnapshot { func structuredContent() -> Value { .object([ - "runId": .string(runID), - "cwd": .string(cwd), - "targetSummary": .string(targetSummary), - "run": core.run.structuredContent(), - "lifecycle": core.structuredLifecycleContent( - elapsedSeconds: elapsedSeconds, - cancellable: cancellable + "runId": .string(result.runID.rawValue), + "cwd": .string(result.cwd), + "targetSummary": .string(result.targetSummary), + "run": result.core.structuredRunContent(), + "lifecycle": result.presentation.structuredLifecycleContent( + core: result.core, + elapsedSeconds: result.elapsedSeconds, + cancellable: result.presentation.isCancellable + ), + "review": result.core.structuredReviewContent( + resolvedFinalReview: log.finalResult ), - "review": core.structuredReviewContent(), ]) } } -private extension CodexReviewAPI.List.Result { +private extension CodexReviewMCP.Tool.ReviewListSnapshot { func structuredContent() -> Value { .object([ "items": .array(items.map { $0.structuredContent() }) @@ -370,81 +280,337 @@ private extension CodexReviewAPI.List.Result { } } -private extension CodexReviewAPI.Cancel.Outcome { +private extension CodexReviewMCP.Tool.ReviewCancelSnapshot { func textContent() -> String { - if cancelled { - core.lifecycle.cancellation?.message ?? "Review cancelled." + if result.cancelled { + result.presentation.cancellation?.message ?? "Review cancelled." } else { "Review was already finished." } } func structuredContent() -> Value { - .object([ - "runId": .string(runID), - "cancelled": .bool(cancelled), - "run": core.run.structuredContent(), - "lifecycle": core.structuredLifecycleContent( + return .object([ + "runId": .string(result.runID.rawValue), + "cancelled": .bool(result.cancelled), + "run": result.core.structuredRunContent(), + "lifecycle": result.presentation.structuredLifecycleContent( + core: result.core, elapsedSeconds: nil, - cancellable: false + cancellable: result.presentation.isCancellable + ), + "review": result.core.structuredReviewContent( + resolvedFinalReview: log.finalResult ), - "review": core.structuredReviewContent(), ]) } } -private extension ReviewRunCore.Run { +private extension ReviewAttempt { func structuredContent() -> Value { .object([ - "reviewThreadId": reviewThreadID.map(Value.string) ?? .null, - "threadId": threadID.map(Value.string) ?? .null, - "turnId": turnID.map(Value.string) ?? .null, + "attemptId": .string(attemptID.rawValue), + "reviewThreadId": .string(threadIdentity.activeTurnThreadID.rawValue), + "threadId": .string(threadIdentity.sourceThreadID.rawValue), + "turnId": .string(turnID.rawValue), "model": model.map(Value.string) ?? .null, ]) } } -private extension ReviewRunCore.Lifecycle { +private extension ReviewRunPresentation { func structuredContent( + core: ReviewRunCore, elapsedSeconds: Int?, - cancellable: Bool, - message: String + cancellable: Bool ) -> Value { - .object([ + let failure: ReviewBackendFailure? + let cancellation: ReviewCancellation? + switch lifecycle { + case .failed(let value): + failure = value + cancellation = nil + case .cancelling(let value), .cancelled(let value): + failure = nil + cancellation = value + case .queued, .starting, .running, .waitingForNetwork, .preparingRestart, + .restarting, .succeeded: + failure = nil + cancellation = nil + } + var object: [String: Value] = [ "status": .string(status.rawValue), - "message": .string(message), - "exitCode": exitCode.map(Value.int) ?? .null, - "startedAt": startedAt.map { .string($0.ISO8601Format()) } ?? .null, - "endedAt": endedAt.map { .string($0.ISO8601Format()) } ?? .null, + "message": .string(lifecycle.message), + "exitCode": .null, + "startedAt": core.startedAt.map { .string($0.ISO8601Format()) } ?? .null, + "endedAt": core.endedAt.map { .string($0.ISO8601Format()) } ?? .null, "elapsedSeconds": elapsedSeconds.map(Value.int) ?? .null, "cancellable": .bool(cancellable), - "cancellation": cancellation.map { $0.structuredContent() } ?? .null, - "errorMessage": errorMessage.map(Value.string) ?? .null, + ] + object["cancellation"] = cancellation.map { $0.structuredContent() } ?? .null + object["errorMessage"] = failure.map { .string($0.message) } ?? .null + object["failure"] = failure.map { $0.structuredContent() } ?? .null + return .object(object) + } +} + +private extension ReviewBackendFailure { + func structuredContent() -> Value { + var object: [String: Value] = [ + "message": .string(message), + ] + switch self { + case .operation(let failure): + object["kind"] = .string("operation") + object["operation"] = failure.structuredContent() + case .missingReviewOutput(let turnID): + object["kind"] = .string("missingReviewOutput") + object["turnId"] = .string(turnID.rawValue) + case .outputPublication(let failure): + object["kind"] = .string("outputPublication") + object["outputPublication"] = failure.structuredContent() + case .invalidTerminalStatus(let rawStatus, let turnID, let turnFailure): + object["kind"] = .string("invalidTerminalStatus") + object["rawStatus"] = .string(rawStatus) + object["turnId"] = .string(turnID.rawValue) + object["turnFailure"] = turnFailure.map { $0.structuredContent() } ?? .null + case .turnFailed(let turnFailure): + object["kind"] = .string("turnFailed") + object["turnFailure"] = turnFailure.structuredContent() + case .interruptedByBackend(let backendMessage): + object["kind"] = .string("interruptedByBackend") + object["backendMessage"] = backendMessage.map(Value.string) ?? .null + case .connectionTerminated(let termination): + object["kind"] = .string("connectionTerminated") + object["connectionTermination"] = termination.structuredContent() + case .retentionJournal: + object["kind"] = .string("retentionJournal") + case .connectivityObservationEnded: + object["kind"] = .string("connectivityObservationEnded") + case .prepareRestartCancelledUnexpectedly: + object["kind"] = .string("prepareRestartCancelledUnexpectedly") + case .restartCancelledUnexpectedly: + object["kind"] = .string("restartCancelledUnexpectedly") + case .protocolViolation: + object["kind"] = .string("protocolViolation") + } + return .object(object) + } +} + +private extension ReviewBackendOperationFailure { + func structuredContent() -> Value { + .object([ + "operation": .string(operation.rawValue), + "message": .string(message), + "reason": reason.structuredContent(), ]) } } +private extension ReviewBackendOperationFailure.Reason { + func structuredContent() -> Value { + var object: [String: Value] = [:] + switch self { + case .launch(let kind): + object["kind"] = .string("launch") + object["launchKind"] = .string(kind.rawValue) + case .request(let requestID, let method, let kind): + object["kind"] = .string("request") + object["requestId"] = .int(requestID) + object["method"] = .string(method) + object["requestFailure"] = kind.structuredContent() + case .connectionTerminated(let termination): + object["kind"] = .string("connectionTerminated") + object["connectionTermination"] = termination.structuredContent() + case .turnDeadlineExceeded(let turnID, let duration): + object["kind"] = .string("turnDeadlineExceeded") + object["turnId"] = .string(turnID.rawValue) + object["duration"] = .string(String(describing: duration)) + case .malformedNotification(let method): + object["kind"] = .string("malformedNotification") + object["method"] = .string(method) + case .reviewRestartUnavailable: + object["kind"] = .string("reviewRestartUnavailable") + } + return .object(object) + } +} + +private extension ReviewBackendOperationFailure.RequestKind { + func structuredContent() -> Value { + var object: [String: Value] = [:] + switch self { + case .encode: + object["kind"] = .string("encode") + case .write: + object["kind"] = .string("write") + case .transport: + object["kind"] = .string("transport") + case .server(let code, let turnFailure): + object["kind"] = .string("server") + object["code"] = .int(code) + object["turnFailure"] = turnFailure.map { $0.structuredContent() } ?? .null + case .invalidResponse(let expectedType): + object["kind"] = .string("invalidResponse") + object["expectedType"] = .string(expectedType) + case .deadlineExceeded: + object["kind"] = .string("deadlineExceeded") + case .overloadRetryExhausted(let lastCode, let lastTurnFailure, let attempts): + object["kind"] = .string("overloadRetryExhausted") + object["lastCode"] = .int(lastCode) + object["lastTurnFailure"] = lastTurnFailure.map { $0.structuredContent() } ?? .null + object["attempts"] = .int(attempts) + } + return .object(object) + } +} + +private extension ReviewOutputPublicationFailure { + func structuredContent() -> Value { + var object: [String: Value] = ["message": .string(message)] + switch self { + case .refreshFailed(let turnID, _): + object["kind"] = .string("refreshFailed") + object["turnId"] = .string(turnID.rawValue) + case .unavailable(let turnID): + object["kind"] = .string("unavailable") + object["turnId"] = .string(turnID.rawValue) + case .empty(let turnID): + object["kind"] = .string("empty") + object["turnId"] = .string(turnID.rawValue) + case .mismatched(let turnID): + object["kind"] = .string("mismatched") + object["turnId"] = .string(turnID.rawValue) + } + return .object(object) + } +} + +private extension ReviewBackendConnectionTermination { + func structuredContent() -> Value { + switch self { + case .closed: + .object(["kind": .string("closed")]) + case .transport(let message): + .object([ + "kind": .string("transport"), + "message": .string(message), + ]) + case .processExited(let status): + .object([ + "kind": .string("processExited"), + "status": status.map { .int(Int($0)) } ?? .null, + ]) + } + } +} + +private extension ReviewTurnFailure { + func structuredContent() -> Value { + .object([ + "message": .string(message), + "code": code.map { $0.structuredContent() } ?? .null, + "additionalDetails": additionalDetails.map(Value.string) ?? .null, + ]) + } +} + +private extension ReviewTurnFailure.Code { + func structuredContent() -> Value { + var object: [String: Value] = [:] + switch self { + case .contextWindowExceeded: + object["name"] = .string("contextWindowExceeded") + case .sessionBudgetExceeded: + object["name"] = .string("sessionBudgetExceeded") + case .usageLimitExceeded: + object["name"] = .string("usageLimitExceeded") + case .serverOverloaded: + object["name"] = .string("serverOverloaded") + case .cyberPolicy: + object["name"] = .string("cyberPolicy") + case .httpConnectionFailed(let status): + object["name"] = .string("httpConnectionFailed") + object["status"] = status.map { .int(Int($0)) } ?? .null + case .responseStreamConnectionFailed(let status): + object["name"] = .string("responseStreamConnectionFailed") + object["status"] = status.map { .int(Int($0)) } ?? .null + case .internalServerError: + object["name"] = .string("internalServerError") + case .unauthorized: + object["name"] = .string("unauthorized") + case .badRequest: + object["name"] = .string("badRequest") + case .threadRollbackFailed: + object["name"] = .string("threadRollbackFailed") + case .sandboxError: + object["name"] = .string("sandboxError") + case .responseStreamDisconnected(let status): + object["name"] = .string("responseStreamDisconnected") + object["status"] = status.map { .int(Int($0)) } ?? .null + case .responseTooManyFailedAttempts(let status): + object["name"] = .string("responseTooManyFailedAttempts") + object["status"] = status.map { .int(Int($0)) } ?? .null + case .activeTurnNotSteerable(let kind): + object["name"] = .string("activeTurnNotSteerable") + object["kind"] = .string(kind) + case .other: + object["name"] = .string("other") + case .unknown(let rawValue): + object["name"] = .string("unknown") + object["rawValue"] = .string(rawValue) + } + return .object(object) + } +} + private extension ReviewRunCore { - var displayLifecycleMessage: String { - lifecycle.errorMessage?.nilIfEmpty ?? lifecycleMessage.nilIfEmpty ?? lifecycle.status.rawValue + func structuredRunContent() -> Value { + attempt?.structuredContent() + ?? .object([ + "attemptId": .null, + "reviewThreadId": .null, + "threadId": .null, + "turnId": .null, + "model": .null, + ]) + } + +} + +private extension ReviewRunPresentation { + var cancellation: ReviewCancellation? { + switch lifecycle { + case .cancelling(let cancellation), .cancelled(let cancellation): + cancellation + case .queued, .starting, .running, .waitingForNetwork, .preparingRestart, + .restarting, .succeeded, .failed: + nil + } } func structuredLifecycleContent( + core: ReviewRunCore, elapsedSeconds: Int?, cancellable: Bool ) -> Value { - lifecycle.structuredContent( + structuredContent( + core: core, elapsedSeconds: elapsedSeconds, - cancellable: cancellable, - message: displayLifecycleMessage + cancellable: cancellable ) } +} +private extension ReviewRunCore { // The run record is not the transcript's source of truth; read paths that // hold a log projection pass the resolved final review so structured // fields match the text content. func structuredReviewContent(resolvedFinalReview: String? = nil) -> Value { - let finalReview = (resolvedFinalReview ?? self.finalReview)?.nilIfEmpty + let finalReview = resolvedFinalReview.flatMap { value -> String? in + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value + } return .object([ "hasFinalReview": .bool(finalReview != nil), "finalReview": finalReview.map(Value.string) ?? .null, diff --git a/Sources/CodexReviewMCPServer/MCPReviewSessionRegistry.swift b/Sources/CodexReviewMCPServer/MCPReviewSessionRegistry.swift new file mode 100644 index 00000000..1e157514 --- /dev/null +++ b/Sources/CodexReviewMCPServer/MCPReviewSessionRegistry.swift @@ -0,0 +1,337 @@ +import Foundation +import CodexReviewKit + +package enum MCPReviewSessionCloseReason: String, Equatable, Sendable { + case delete + case timeout + case serverStop + case initializationFailure +} + +package struct MCPReviewStartReservation: Hashable, Sendable { + package let sessionID: String + fileprivate let id: UUID +} + +package struct MCPSessionOperationToken: Hashable, Sendable { + package let sessionID: String + fileprivate let id: UUID +} + +package struct MCPReviewSessionCloseReport: Equatable, Sendable { + package let sessionID: String + package let reason: MCPReviewSessionCloseReason + package let members: Set + package let cancellationScheduled: Set + package let cancellationFinished: Set + package let cancellationFailed: Set +} + +package struct MCPReviewSessionStoreCloseResult: Equatable, Sendable { + package let terminalAndDrainedRunIDs: Set + package let failedRunIDs: Set + + package init( + terminalAndDrainedRunIDs: Set, + failedRunIDs: Set = [] + ) { + self.terminalAndDrainedRunIDs = terminalAndDrainedRunIDs + self.failedRunIDs = failedRunIDs + } +} + +package enum MCPReviewSessionRegistryError: LocalizedError, Equatable, Sendable { + case sessionNotOpen(String) + case invalidStartReservation + case invalidOperationToken + case runNotFound(ReviewRunID) + + package var errorDescription: String? { + switch self { + case .sessionNotOpen(let sessionID): + "MCP session \(sessionID) is closed." + case .invalidStartReservation: + "The MCP review start reservation is not owned by this session." + case .invalidOperationToken: + "The MCP operation token is not owned by this session." + case .runNotFound(let runID): + "Run \(runID.rawValue) was not found." + } + } +} + +package actor MCPReviewSessionRegistry { + package enum Phase: Sendable { + case open + case closing( + reason: MCPReviewSessionCloseReason, + completion: Task + ) + case closed(MCPReviewSessionCloseReport) + } + + package struct SessionState: Sendable { + package var phase: Phase + package var members: Set + package var pendingStarts: Set + package var operations: Set + package var cancellationScheduled: Set + package var cancellationFinished: Set + } + + package enum BindDisposition: Equatable, Sendable { + case bound + case sessionClosing(MCPReviewSessionCloseReason) + } + + private let closeStoreSession: + @MainActor @Sendable (String) async -> MCPReviewSessionStoreCloseResult + private let releaseStoreSession: + @MainActor @Sendable (String) -> Void + private var sessions: [String: SessionState] = [:] + private var drainWaiters: [String: [CheckedContinuation]] = [:] + + package init( + closeStoreSession: @escaping @MainActor @Sendable (String) async -> MCPReviewSessionStoreCloseResult, + releaseStoreSession: @escaping @MainActor @Sendable (String) -> Void + ) { + self.closeStoreSession = closeStoreSession + self.releaseStoreSession = releaseStoreSession + } + + package func openSession(_ sessionID: String) { + precondition(sessions[sessionID] == nil, "An MCP session identity can only be opened once.") + sessions[sessionID] = SessionState( + phase: .open, + members: [], + pendingStarts: [], + operations: [], + cancellationScheduled: [], + cancellationFinished: [] + ) + } + + package func reserveStart(in sessionID: String) throws -> MCPReviewStartReservation { + var state = try requireOpenSession(sessionID) + let reservation = MCPReviewStartReservation(sessionID: sessionID, id: UUID()) + state.pendingStarts.insert(reservation) + sessions[sessionID] = state + return reservation + } + + package func bind( + runID: ReviewRunID, + reservation: MCPReviewStartReservation + ) throws -> BindDisposition { + guard var state = sessions[reservation.sessionID], + state.pendingStarts.remove(reservation) != nil else { + throw MCPReviewSessionRegistryError.invalidStartReservation + } + + state.members.insert(runID) + let disposition: BindDisposition + switch state.phase { + case .open: + disposition = .bound + case .closing(let reason, _): + state.cancellationScheduled.insert(runID) + disposition = .sessionClosing(reason) + case .closed(let report): + disposition = .sessionClosing(report.reason) + } + sessions[reservation.sessionID] = state + signalDrainIfNeeded(sessionID: reservation.sessionID) + return disposition + } + + package func finishStart(_ reservation: MCPReviewStartReservation) throws { + guard var state = sessions[reservation.sessionID], + state.pendingStarts.remove(reservation) != nil else { + throw MCPReviewSessionRegistryError.invalidStartReservation + } + sessions[reservation.sessionID] = state + signalDrainIfNeeded(sessionID: reservation.sessionID) + } + + package func beginOperation( + in sessionID: String, + requiringMember runID: ReviewRunID? = nil + ) throws -> MCPSessionOperationToken { + var state = try requireOpenSession(sessionID) + if let runID, state.members.contains(runID) == false { + throw MCPReviewSessionRegistryError.runNotFound(runID) + } + let token = MCPSessionOperationToken(sessionID: sessionID, id: UUID()) + state.operations.insert(token) + sessions[sessionID] = state + return token + } + + package func finishOperation(_ token: MCPSessionOperationToken) throws { + guard var state = sessions[token.sessionID], + state.operations.remove(token) != nil else { + throw MCPReviewSessionRegistryError.invalidOperationToken + } + sessions[token.sessionID] = state + signalDrainIfNeeded(sessionID: token.sessionID) + } + + package func validateMember( + _ runID: ReviewRunID, + for operation: MCPSessionOperationToken + ) throws { + let state = try requireActiveOperation(operation) + guard state.members.contains(runID) else { + throw MCPReviewSessionRegistryError.runNotFound(runID) + } + } + + package func members( + for operation: MCPSessionOperationToken + ) throws -> Set { + try requireActiveOperation(operation).members + } + + package func hasPendingWork(in sessionID: String) -> Bool { + guard let state = sessions[sessionID] else { + return false + } + return state.pendingStarts.isEmpty == false || state.operations.isEmpty == false + } + + package func beginClose( + _ sessionID: String, + reason: MCPReviewSessionCloseReason + ) -> Task { + guard var state = sessions[sessionID] else { + let report = MCPReviewSessionCloseReport( + sessionID: sessionID, + reason: reason, + members: [], + cancellationScheduled: [], + cancellationFinished: [], + cancellationFailed: [] + ) + return Task { report } + } + + switch state.phase { + case .open: + state.cancellationScheduled.formUnion(state.members) + let completion = Task { [self] in + await driveClose(sessionID: sessionID, reason: reason) + } + state.phase = .closing(reason: reason, completion: completion) + sessions[sessionID] = state + return completion + case .closing(_, let completion): + return completion + case .closed(let report): + return Task { report } + } + } + + package func removeClosedSession(_ sessionID: String) { + guard let state = sessions[sessionID] else { + return + } + guard case .closed = state.phase else { + preconditionFailure("An MCP session can only be removed after close completes.") + } + precondition(drainWaiters[sessionID]?.isEmpty ?? true) + drainWaiters.removeValue(forKey: sessionID) + sessions.removeValue(forKey: sessionID) + } + + package func stateForTesting(sessionID: String) -> SessionState? { + sessions[sessionID] + } + + package func sessionCountForTesting() -> Int { + sessions.count + } + + func registerMemberForTesting(_ runID: ReviewRunID, in sessionID: String) throws { + var state = try requireOpenSession(sessionID) + state.members.insert(runID) + sessions[sessionID] = state + } + + private func requireOpenSession(_ sessionID: String) throws -> SessionState { + guard let state = sessions[sessionID], case .open = state.phase else { + throw MCPReviewSessionRegistryError.sessionNotOpen(sessionID) + } + return state + } + + private func requireActiveOperation( + _ operation: MCPSessionOperationToken + ) throws -> SessionState { + guard let state = sessions[operation.sessionID], + state.operations.contains(operation) else { + throw MCPReviewSessionRegistryError.invalidOperationToken + } + return state + } + + private func driveClose( + sessionID: String, + reason: MCPReviewSessionCloseReason + ) async -> MCPReviewSessionCloseReport { + let initialClose = await closeStoreSession(sessionID) + await waitUntilDrained(sessionID: sessionID) + // A reserved start can create and bind its run while the first close + // call is suspended. Re-enter the store close boundary after every + // reservation and operation has released so that late membership is + // cancelled by the store owner before being reported as finished. + let finalClose = await closeStoreSession(sessionID) + + guard var state = sessions[sessionID] else { + preconditionFailure("The registry must retain a closing session until its driver completes.") + } + await releaseStoreSession(sessionID) + state.cancellationScheduled.formUnion(state.members) + let provenFinished = initialClose.terminalAndDrainedRunIDs + .union(finalClose.terminalAndDrainedRunIDs) + .intersection(state.cancellationScheduled) + let reportedFailures = initialClose.failedRunIDs + .union(finalClose.failedRunIDs) + .union(state.cancellationScheduled.subtracting(provenFinished)) + .subtracting(provenFinished) + .intersection(state.cancellationScheduled) + state.cancellationFinished = provenFinished + let report = MCPReviewSessionCloseReport( + sessionID: sessionID, + reason: reason, + members: state.members, + cancellationScheduled: state.cancellationScheduled, + cancellationFinished: state.cancellationFinished, + cancellationFailed: reportedFailures + ) + state.phase = .closed(report) + sessions[sessionID] = state + return report + } + + private func waitUntilDrained(sessionID: String) async { + guard let state = sessions[sessionID], + state.pendingStarts.isEmpty == false || state.operations.isEmpty == false else { + return + } + await withCheckedContinuation { continuation in + drainWaiters[sessionID, default: []].append(continuation) + } + } + + private func signalDrainIfNeeded(sessionID: String) { + guard let state = sessions[sessionID], + state.pendingStarts.isEmpty, + state.operations.isEmpty else { + return + } + let waiters = drainWaiters.removeValue(forKey: sessionID) ?? [] + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Sources/CodexReviewMCPServer/ReviewChatProjectionLookup.swift b/Sources/CodexReviewMCPServer/ReviewChatProjectionLookup.swift new file mode 100644 index 00000000..980b990d --- /dev/null +++ b/Sources/CodexReviewMCPServer/ReviewChatProjectionLookup.swift @@ -0,0 +1,25 @@ +import Foundation +import CodexDataKit +import CodexReviewKit + +package enum ReviewChatProjectionLookup: Sendable, Equatable { + case available(ReviewMCPLogProjection) + case unavailable + case refreshFailed(CodexFetchFailure) +} + +package enum ReviewMCPError: Error, Sendable, Equatable { + case projectionInvariantViolation(runID: ReviewRunID) + case projectionRefreshFailed(runID: ReviewRunID, failure: CodexFetchFailure) +} + +extension ReviewMCPError: LocalizedError { + package var errorDescription: String? { + switch self { + case .projectionInvariantViolation(let runID): + "Review output projection invariant failed for run \(runID.rawValue)." + case .projectionRefreshFailed(let runID, let failure): + "Review output projection refresh failed for run \(runID.rawValue): \(failure.localizedDescription)" + } + } +} diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 3e1682c8..b8f9a5d7 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -1,5 +1,5 @@ import Foundation -import CodexKit +import CodexAppServerKit import CodexReviewKit package struct ReviewMCPLogProjection: Sendable, Equatable { @@ -26,38 +26,27 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } } - var revision: String - var orderedEntryIDs: [String] - var activeEntryIDs: [String] - var activeEntryCount: Int - var latestEntryID: String? - var finalLifecycleMessage: String? + var turnID: CodexTurnID? var finalResult: String? var items: [Item] - static func unavailable(result: CodexReviewAPI.Read.Result) -> Self { - Self(result: result) + static func unavailable(result _: CodexReviewAPI.Read.Result) -> Self { + Self() } - private init(result: CodexReviewAPI.Read.Result) { - self.revision = "\(result.runID):unavailable" + private init() { self.items = [] - self.orderedEntryIDs = [] - self.activeEntryIDs = [] - self.activeEntryCount = activeEntryIDs.count - self.latestEntryID = orderedEntryIDs.last - self.finalLifecycleMessage = nil + self.turnID = nil self.finalResult = nil } init( result: CodexReviewAPI.Read.Result, turnID: CodexTurnID, - threadItems: [CodexThreadItem] + threadItems: [CodexThreadItem], + reviewOutputText: String? ) { - let lifecycle = result.core.lifecycle - let lifecycleMessage = result.core.lifecycleMessage - let status = lifecycle.status + let status = result.presentation.status let projectedItems = threadItems.compactMap { item -> Item? in guard let content = Content(threadItem: item) else { return nil @@ -68,65 +57,23 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { content: content ) } - let itemRevision = threadItems - .map { item in - // Digest the content, not just its length, so same-length - // edits still advance the revision clients compare. - "\(item.id):\(item.kind.rawValue):\(item.text?.stableLogDigest ?? "0")" - } - .joined(separator: "|") - self.revision = [ - result.runID, - status.rawValue, - lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", - turnID.rawValue, - itemRevision, - ].joined(separator: ":") self.items = projectedItems - self.orderedEntryIDs = projectedItems.map(\.id) - self.activeEntryIDs = status.isTerminal ? [] : projectedItems.map(\.id) - self.activeEntryCount = activeEntryIDs.count - self.latestEntryID = orderedEntryIDs.last - self.finalLifecycleMessage = status.isTerminal ? lifecycleMessage : nil + self.turnID = turnID self.finalResult = status == .succeeded - ? result.core.finalReview ?? projectedItems.lastAssistantMessageText + ? reviewOutputText.flatMap(Self.nonEmptyReviewOutput) : nil } -} - -private extension [ReviewMCPLogProjection.Item] { - // Only agent messages qualify as the final result; a trailing user - // prompt in the transcript must never replace the review findings. - var lastAssistantMessageText: String? { - reversed() - .filter { $0.kind == CodexThreadItem.Kind.agentMessage.rawValue } - .compactMap { $0.content.messageText } - .first - } -} -private extension String { - // Process-stable FNV-1a digest; revisions are only compared against - // other revisions produced by the same server instance. - var stableLogDigest: String { - var hash: UInt64 = 0xcbf2_9ce4_8422_2325 - for byte in utf8 { - hash ^= UInt64(byte) - hash = hash &* 0x0000_0100_0000_01b3 + private static func nonEmptyReviewOutput(_ value: String) -> String? { + guard value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return nil } - return String(hash, radix: 16) + return value } } private extension ReviewMCPLogProjection.Content { - var messageText: String? { - guard case .message(let text) = self else { - return nil - } - return text.nilIfEmpty - } - init?(threadItem item: CodexThreadItem) { switch item.content { case .message(let message): diff --git a/Sources/CodexReviewMCPServer/ReviewRunIDArgument.swift b/Sources/CodexReviewMCPServer/ReviewRunIDArgument.swift index 5206e1b8..7310e765 100644 --- a/Sources/CodexReviewMCPServer/ReviewRunIDArgument.swift +++ b/Sources/CodexReviewMCPServer/ReviewRunIDArgument.swift @@ -17,12 +17,19 @@ enum ReviewRunIDArgument { }) } - static func optionalValue(in arguments: [String: Value]) throws -> String? { - let provided = acceptedNames.compactMap { name -> (name: String, value: String)? in - guard let value = arguments[name]?.stringValue?.nilIfEmpty else { + static func optionalValue(in arguments: [String: Value]) throws -> ReviewRunID? { + let provided = try acceptedNames.compactMap { name -> (name: String, value: ReviewRunID)? in + guard let argument = arguments[name] else { return nil } - return (name, value) + guard let rawValue = argument.stringValue else { + throw MCPProtocolServerError.invalidArgument("\(name) must be a string.") + } + do { + return (name, try ReviewRunID(validating: rawValue)) + } catch { + throw MCPProtocolServerError.invalidArgument("\(name) must not be empty.") + } } guard let first = provided.first else { return nil @@ -35,7 +42,7 @@ enum ReviewRunIDArgument { return first.value } - static func requiredValue(in arguments: [String: Value]) throws -> String { + static func requiredValue(in arguments: [String: Value]) throws -> ReviewRunID { guard let runID = try optionalValue(in: arguments) else { throw MCPProtocolServerError.missingArgument(requiredDescription) } diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index a1e913d6..33020ebc 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -23,10 +23,11 @@ package actor ManualClock { } } -package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitoring, @unchecked Sendable { +package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitoring, Sendable { private struct State { var current: CodexReviewNetworkSnapshot? var continuations: [UUID: AsyncStream.Continuation] = [:] + var isFinished = false } private let state: Mutex @@ -37,14 +38,20 @@ package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitorin package func snapshots() -> AsyncStream { let continuationID = UUID() - return AsyncStream(bufferingPolicy: .unbounded) { continuation in - let snapshot = state.withLock { state in - state.continuations[continuationID] = continuation - return state.current + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let initial = state.withLock { state in + if state.isFinished == false { + state.continuations[continuationID] = continuation + } + return (snapshot: state.current, isFinished: state.isFinished) } - if let snapshot { + if let snapshot = initial.snapshot { continuation.yield(snapshot) } + if initial.isFinished { + continuation.finish() + return + } continuation.onTermination = { [weak self] _ in self?.removeContinuation(id: continuationID) } @@ -53,6 +60,9 @@ package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitorin package func yield(_ snapshot: CodexReviewNetworkSnapshot) { let continuations = state.withLock { state in + guard state.isFinished == false else { + return [AsyncStream.Continuation]() + } state.current = snapshot return Array(state.continuations.values) } @@ -61,6 +71,21 @@ package final class ManualCodexReviewNetworkMonitor: CodexReviewNetworkMonitorin } } + package func finish() { + let continuations = state.withLock { state in + guard state.isFinished == false else { + return [AsyncStream.Continuation]() + } + state.isFinished = true + let continuations = Array(state.continuations.values) + state.continuations.removeAll(keepingCapacity: false) + return continuations + } + for continuation in continuations { + continuation.finish() + } + } + private func removeContinuation(id: UUID) { _ = state.withLock { state in state.continuations.removeValue(forKey: id) @@ -112,62 +137,188 @@ private func withFakeBackendTimeout( } } +package func makeReviewAttemptForTesting( + attemptID: String, + sourceThreadID: String, + activeTurnThreadID: String, + turnID: String, + model: String? = nil +) -> ReviewAttempt { + do { + return try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: sourceThreadID, + activeTurnThreadID: activeTurnThreadID, + turnID: turnID, + model: model + ) + } catch { + preconditionFailure("Invalid explicit review attempt fixture: \(error)") + } +} + +package enum FakeReviewTerminal: Equatable, Sendable { + case completed(finalReview: String) + case interrupted(message: String?) + case failed(ReviewBackendFailure) + case cancelled(String) +} + +private actor FakeReviewTerminalSource { + private enum Delivery: Sendable { + case observed(ReviewBackendObservedTerminal) + case cancelled + } + + private let attempt: ReviewAttempt + private var delivery: Delivery? + private var waiters: [UUID: CheckedContinuation] = [:] + + init(attempt: ReviewAttempt) { + self.attempt = attempt + } + + func observe() async throws -> ReviewBackendObservedTerminal { + let waiterID = UUID() + let received: Delivery = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if let delivery = self.delivery { + continuation.resume(returning: delivery) + } else { + waiters[waiterID] = continuation + } + } + } onCancel: { + Task { + await self.cancelWaiter(waiterID) + } + } + switch received { + case .observed(let terminal): + return terminal + case .cancelled: + throw CancellationError() + } + } + + func observedIfKnown() -> ReviewBackendObservedTerminal? { + guard case .observed(let terminal) = delivery else { + return nil + } + return terminal + } + + func yield(_ terminal: FakeReviewTerminal) { + let observed: ReviewBackendObservedTerminal + switch terminal { + case .completed(let finalReview): + do { + observed = .completed(.init( + turnID: attempt.turnID, + expectedOutput: try NonEmptyReviewOutput(validating: finalReview) + )) + } catch { + observed = .failed(.missingReviewOutput(turnID: attempt.turnID)) + } + case .interrupted(let message): + observed = .interrupted(message: message) + case .cancelled(let message): + observed = .interrupted(message: message) + case .failed(let failure): + observed = .failed(failure) + } + resolve(.observed(observed)) + } + + func finish(throwing error: (any Error)? = nil) { + if error is CancellationError { + resolve(.cancelled) + } else { + resolve(.observed(.failed(.protocolViolation( + message: error.map { + "Review terminal source failed without a typed terminal: \($0.localizedDescription)" + } ?? "Review terminal source finished without a typed terminal." + )))) + } + } + + private func resolve(_ delivery: Delivery) { + guard self.delivery == nil else { + return + } + self.delivery = delivery + let waiters = Array(waiters.values) + self.waiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume(returning: delivery) + } + } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume(returning: .cancelled) + } +} + package actor FakeCodexReviewBackend: CodexReviewBackend { package enum Command: Equatable, Sendable { case readSettings case applySettings(CodexReviewBackendModel.Settings.Change) case readAuth - case startLogin(CodexReviewBackendModel.Login.Request) - case cancelLogin(CodexReviewBackendModel.Login.Challenge) case logout(CodexReviewBackendModel.Account.ID) case startReview(CodexReviewBackendModel.Review.Start) - case interruptReview(CodexReviewBackendModel.Review.Run, CodexReviewBackendModel.CancellationReason) - case prepareReviewRestart(CodexReviewBackendModel.Review.Run) + case interruptReview(ReviewAttempt, CodexReviewBackendModel.CancellationReason) + case prepareReviewRestart(ReviewAttempt) case restartPreparedReview(CodexReviewBackendModel.Review.RestartToken, CodexReviewBackendModel.Review.Start) - case cleanupReview(CodexReviewBackendModel.Review.Run) + case discardPreparedReviewRestart(CodexReviewBackendModel.Review.RestartToken) + case cleanupReview(ReviewAttempt) + case cleanupRetainedReviews([ReviewAttempt]) } private var settings: CodexReviewBackendModel.Settings.Snapshot private var auth: CodexReviewBackendModel.Auth.Snapshot private var commands: [Command] = [] - private var nextRun: CodexReviewBackendModel.Review.Run - private var nextRecoveredRun: CodexReviewBackendModel.Review.Run? + private var plannedAttempts: [ReviewAttempt] + private var plannedRecoveredAttempts: [ReviewAttempt] = [] + private var discardedRestartAttempts: [ReviewAttempt] = [] + private var preparedRestartTokens: [String: CodexReviewBackendModel.Review.RestartToken] = [:] private var interruptFailureMessage: String? private var recoveryFailureMessage: String? + private var retainedCleanupFailureMessage: String? + private var retainedCleanupGate: AsyncGate? + private var retainedCleanupWaiters: [UUID: CheckedContinuation] = [:] private var interruptReviewGate: AsyncGate? - private var interruptReviewWaiters: [UUID: CheckedContinuation] = [:] + private struct InterruptReviewWaiter { + var requiredCount: Int + var continuation: CheckedContinuation + } + + private var interruptReviewWaiters: [UUID: InterruptReviewWaiter] = [:] private var prepareReviewRestartWaiters: [UUID: CheckedContinuation] = [:] private var startReviewGate: AsyncGate? + private var startReviewIgnoresCancellation = false private var startReviewWaiters: [UUID: CheckedContinuation] = [:] private var restartPreparedReviewGate: AsyncGate? + private var restartPreparedReviewIgnoresCancellation = false private var restartPreparedReviewWaiters: [UUID: CheckedContinuation] = [:] - private var eventMailboxes: [EventMailboxKey: BackendReviewEventMailbox] = [:] + private var cleanupReviewWaiters: [ReviewAttemptID: [UUID: CheckedContinuation]] = [:] + private var terminalSources: [TerminalSourceKey: FakeReviewTerminalSource] = [:] - private struct EventMailboxKey: Hashable, Sendable { - var attemptID: String - var threadID: String - var turnID: String? - var reviewThreadID: String? - var model: String? + private struct TerminalSourceKey: Hashable, Sendable { + var attempt: ReviewAttempt - init(run: CodexReviewBackendModel.Review.Run) { - self.attemptID = run.attemptID - self.threadID = run.threadID - self.turnID = run.turnID - self.reviewThreadID = run.reviewThreadID - self.model = run.model + init(attempt: ReviewAttempt) { + self.attempt = attempt } } package init( settings: CodexReviewBackendModel.Settings.Snapshot = .init(), auth: CodexReviewBackendModel.Auth.Snapshot = .init(), - nextRun: CodexReviewBackendModel.Review.Run = .init( - threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1") + plannedAttempt: ReviewAttempt? = nil ) { self.settings = settings self.auth = auth - self.nextRun = nextRun + self.plannedAttempts = plannedAttempt.map { [$0] } ?? [] } package func recordedCommands() -> [Command] { @@ -176,6 +327,12 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { package func holdStartReview(with gate: AsyncGate) { startReviewGate = gate + startReviewIgnoresCancellation = false + } + + package func holdStartReviewIgnoringCancellation(with gate: AsyncGate) { + startReviewGate = gate + startReviewIgnoresCancellation = true } package func failInterrupts(message: String) { @@ -186,16 +343,68 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { recoveryFailureMessage = message } + package func failRetainedCleanup(message: String?) { + retainedCleanupFailureMessage = message + } + + package func holdRetainedCleanup(with gate: AsyncGate) { + retainedCleanupGate = gate + } + + package func waitForRetainedCleanup() async { + if commands.contains(where: { + if case .cleanupRetainedReviews = $0 { true } else { false } + }) { + return + } + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if commands.contains(where: { + if case .cleanupRetainedReviews = $0 { true } else { false } + }) { + continuation.resume() + } else { + retainedCleanupWaiters[waiterID] = continuation + } + } + } onCancel: { + Task { + await self.cancelRetainedCleanupWaiter(id: waiterID) + } + } + } + + package func waitForRetainedCleanup(timeout: Duration = .seconds(2)) async throws { + try await withFakeBackendTimeout(operation: "cleanupRetainedReviews", timeout: timeout) { + await self.waitForRetainedCleanup() + } + } + package func holdInterruptReview(with gate: AsyncGate) { interruptReviewGate = gate } package func holdRestartPreparedReview(with gate: AsyncGate) { restartPreparedReviewGate = gate + restartPreparedReviewIgnoresCancellation = false + } + + package func holdRestartPreparedReviewIgnoringCancellation(with gate: AsyncGate) { + restartPreparedReviewGate = gate + restartPreparedReviewIgnoresCancellation = true } - package func setNextRecoveredRun(_ run: CodexReviewBackendModel.Review.Run) { - nextRecoveredRun = run + package func planNextRecoveredAttempt(_ attempt: ReviewAttempt) { + plannedRecoveredAttempts.append(attempt) + } + + package func planNextAttempt(_ attempt: ReviewAttempt) { + plannedAttempts.append(attempt) + } + + package func setDiscardedRestartAttempts(_ attempts: [ReviewAttempt]) { + discardedRestartAttempts = attempts } package func waitForStartReview() async { @@ -237,28 +446,24 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { } package func waitForInterruptReview() async { - if commands.contains(where: { - if case .interruptReview = $0 { - true - } else { - false - } - }) { + await waitForInterruptReviews(count: 1) + } + + package func waitForInterruptReviews(count: Int) async { + precondition(count > 0, "An interrupt waiter must require at least one command.") + if interruptReviewCommandCount >= count { return } let waiterID = UUID() await withTaskCancellationHandler { await withCheckedContinuation { continuation in - if commands.contains(where: { - if case .interruptReview = $0 { - true - } else { - false - } - }) { + if interruptReviewCommandCount >= count { continuation.resume() } else { - interruptReviewWaiters[waiterID] = continuation + interruptReviewWaiters[waiterID] = .init( + requiredCount: count, + continuation: continuation + ) } } } onCancel: { @@ -274,6 +479,15 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { } } + package func waitForInterruptReviews( + count: Int, + timeout: Duration = .seconds(2) + ) async throws { + try await withFakeBackendTimeout(operation: "interruptReview", timeout: timeout) { + await self.waitForInterruptReviews(count: count) + } + } + package func waitForPrepareReviewRestart() async { if commands.contains(where: { if case .prepareReviewRestart = $0 { @@ -350,6 +564,38 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { } } + package func waitForCleanupReview(_ attempt: ReviewAttempt) async { + if commands.contains(.cleanupReview(attempt)) { + return + } + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if commands.contains(.cleanupReview(attempt)) { + continuation.resume() + } else { + cleanupReviewWaiters[attempt.attemptID, default: [:]][waiterID] = continuation + } + } + } onCancel: { + Task { + await self.cancelCleanupReviewWaiter( + attemptID: attempt.attemptID, + waiterID: waiterID + ) + } + } + } + + package func waitForCleanupReview( + _ attempt: ReviewAttempt, + timeout: Duration + ) async throws { + try await withFakeBackendTimeout(operation: "cleanupReview", timeout: timeout) { + await self.waitForCleanupReview(attempt) + } + } + package func readSettings() async throws -> CodexReviewBackendModel.Settings.Snapshot { commands.append(.readSettings) return settings @@ -374,17 +620,6 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { return auth } - package func startLogin(_ request: CodexReviewBackendModel.Login.Request) async throws - -> CodexReviewBackendModel.Login.Challenge - { - commands.append(.startLogin(request)) - return .init(id: "challenge-1") - } - - package func cancelLogin(_ challenge: CodexReviewBackendModel.Login.Challenge) async throws { - commands.append(.cancelLogin(challenge)) - } - package func logout(_ account: CodexReviewBackendModel.Account.ID) async throws -> CodexReviewBackendModel.Auth.Snapshot { @@ -400,23 +635,40 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { for waiter in waiters { waiter.resume() } + guard plannedAttempts.isEmpty == false else { + throw FakeCodexReviewBackendError( + message: "No review attempt was planned for startReview." + ) + } if let startReviewGate { - await startReviewGate.wait() + if startReviewIgnoresCancellation { + await startReviewGate.waitIgnoringCancellation() + } else { + try await startReviewGate.wait() + } + } + guard plannedAttempts.isEmpty == false else { + throw FakeCodexReviewBackendError( + message: "No review attempt remained planned when startReview completed." + ) } - return .init(run: nextRun, events: eventMailbox(for: nextRun)) + let plannedAttempt = plannedAttempts.removeFirst() + return backendAttempt(for: plannedAttempt) } package func interruptReview( - _ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason + _ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason ) async throws { - commands.append(.interruptReview(run, reason)) - let waiters = Array(interruptReviewWaiters.values) - interruptReviewWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume() + commands.append(.interruptReview(attempt, reason)) + let interruptReviewCommandCount = interruptReviewCommandCount + let readyWaiterIDs = interruptReviewWaiters.compactMap { id, waiter in + waiter.requiredCount <= interruptReviewCommandCount ? id : nil + } + for waiterID in readyWaiterIDs { + interruptReviewWaiters.removeValue(forKey: waiterID)?.continuation.resume() } if let interruptReviewGate { - await interruptReviewGate.wait() + try await interruptReviewGate.wait() } if let interruptFailureMessage { throw FakeCodexReviewBackendError(message: interruptFailureMessage) @@ -424,21 +676,29 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { } package func prepareReviewRestart( - _ run: CodexReviewBackendModel.Review.Run + _ attempt: ReviewAttempt ) async throws -> CodexReviewBackendModel.Review.RestartToken { - commands.append(.prepareReviewRestart(run)) + commands.append(.prepareReviewRestart(attempt)) let waiters = Array(prepareReviewRestartWaiters.values) prepareReviewRestartWaiters.removeAll(keepingCapacity: false) for waiter in waiters { waiter.resume() } if let interruptReviewGate { - await interruptReviewGate.wait() + try await interruptReviewGate.wait() } if let interruptFailureMessage { throw FakeCodexReviewBackendError(message: interruptFailureMessage) } - return .init(id: "restart-token-\(run.attemptID)", interruptedRun: run) + let token = CodexReviewBackendModel.Review.RestartToken( + id: "restart-token-\(attempt.attemptID.rawValue)", + interruptedAttempt: attempt + ) + precondition( + preparedRestartTokens.updateValue(token, forKey: token.id) == nil, + "A fake restart token identity can only be prepared once." + ) + return token } package func restartPreparedReview( @@ -446,68 +706,137 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { request: CodexReviewBackendModel.Review.Start ) async throws -> BackendReviewAttempt { commands.append(.restartPreparedReview(token, request)) + guard preparedRestartTokens[token.id] == token else { + preconditionFailure("A fake restart token must be live and consumed exactly once.") + } let waiters = Array(restartPreparedReviewWaiters.values) restartPreparedReviewWaiters.removeAll(keepingCapacity: false) for waiter in waiters { waiter.resume() } if let restartPreparedReviewGate { - await restartPreparedReviewGate.wait() + if restartPreparedReviewIgnoresCancellation { + await restartPreparedReviewGate.waitIgnoringCancellation() + } else { + try await restartPreparedReviewGate.wait() + } } if let recoveryFailureMessage { throw FakeCodexReviewBackendError(message: recoveryFailureMessage) } - let run = token.interruptedRun - let recoveredRun = - nextRecoveredRun - ?? .init( - attemptID: "attempt-recovered", - threadID: run.threadID, - turnID: "turn-recovered", - reviewThreadID: run.reviewThreadID, - model: run.model ?? request.model + guard plannedRecoveredAttempts.isEmpty == false else { + throw FakeCodexReviewBackendError( + message: "No review attempt was planned for restartPreparedReview." ) - return .init(run: recoveredRun, events: eventMailbox(for: recoveredRun)) + } + let recoveredAttempt = plannedRecoveredAttempts.removeFirst() + preparedRestartTokens.removeValue(forKey: token.id) + return backendAttempt(for: recoveredAttempt) } - package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - commands.append(.cleanupReview(run)) + package func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + commands.append(.discardPreparedReviewRestart(token)) + guard preparedRestartTokens.removeValue(forKey: token.id) == token else { + preconditionFailure("A fake restart token must be live and discarded exactly once.") + } + let attempts = discardedRestartAttempts + discardedRestartAttempts = [] + return attempts } - package func yield( - _ event: CodexReviewBackendModel.Review.Event, for run: CodexReviewBackendModel.Review.Run? = nil - ) async { - await eventMailbox(for: run ?? nextRun).append(event) + package func cleanupReview(_ attempt: ReviewAttempt) async { + commands.append(.cleanupReview(attempt)) + let waiters = cleanupReviewWaiters + .removeValue(forKey: attempt.attemptID) + .map { Array($0.values) } ?? [] + for waiter in waiters { + waiter.resume() + } } - package func finishEvents(for run: CodexReviewBackendModel.Review.Run? = nil) async { - await eventMailbox(for: run ?? nextRun).finish() + package func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs _: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + commands.append(.cleanupRetainedReviews(attempts)) + let waiters = Array(retainedCleanupWaiters.values) + retainedCleanupWaiters.removeAll(keepingCapacity: false) + for waiter in waiters { + waiter.resume() + } + if let retainedCleanupGate { + try? await retainedCleanupGate.wait() + } + guard let retainedCleanupFailureMessage, + let attempt = attempts.first + else { + return .init() + } + return .init(failures: [ + .init( + threadID: attempt.threadIdentity.activeTurnThreadID, + message: retainedCleanupFailureMessage + ) + ]) } - package func finishEvents(throwing error: any Error, for run: CodexReviewBackendModel.Review.Run? = nil) async { - await eventMailbox(for: run ?? nextRun).fail(error) + package func yield(_ terminal: FakeReviewTerminal, for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).yield(terminal) + } + + package func finishEvents(for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).finish() + } + + package func finishEvents(throwing error: any Error, for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).finish(throwing: error) } package func finishEventMailboxes() async { - let mailboxes = Array(eventMailboxes.values) - eventMailboxes.removeAll(keepingCapacity: false) - for mailbox in mailboxes { - await mailbox.finish() + let sources = Array(terminalSources.values) + terminalSources.removeAll(keepingCapacity: false) + for source in sources { + await source.finish() } } - package func hasEventMailbox(for run: CodexReviewBackendModel.Review.Run) -> Bool { - eventMailboxes[.init(run: run)] != nil + package func hasEventMailbox(for attempt: ReviewAttempt) -> Bool { + terminalSources[.init(attempt: attempt)] != nil } - private func eventMailbox(for run: CodexReviewBackendModel.Review.Run) -> BackendReviewEventMailbox { - let key = EventMailboxKey(run: run) - if let mailbox = eventMailboxes[key] { - return mailbox + private func terminalSource(for attempt: ReviewAttempt) -> FakeReviewTerminalSource { + let key = TerminalSourceKey(attempt: attempt) + if let source = terminalSources[key] { + return source } - let mailbox = BackendReviewEventMailbox() - eventMailboxes[key] = mailbox - return mailbox + let source = FakeReviewTerminalSource(attempt: attempt) + terminalSources[key] = source + return source + } + + private func backendAttempt(for attempt: ReviewAttempt) -> BackendReviewAttempt { + let source = terminalSource(for: attempt) + return BackendReviewAttempt( + attempt: attempt, + observeTerminal: { + try await source.observe() + }, + observedTerminalIfKnown: { + await source.observedIfKnown() + }, + finalizeTerminal: { observed in + switch observed { + case .completed(let candidate): + .completed(.init(finalReview: candidate.expectedOutput)) + case .interrupted(let message): + .interrupted(message: message) + case .failed(let failure): + .failed(failure) + } + } + ) } private func cancelStartReviewWaiter(id: UUID) { @@ -515,7 +844,15 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { } private func cancelInterruptReviewWaiter(id: UUID) { - interruptReviewWaiters.removeValue(forKey: id)?.resume() + interruptReviewWaiters.removeValue(forKey: id)?.continuation.resume() + } + + private var interruptReviewCommandCount: Int { + commands.reduce(into: 0) { count, command in + if case .interruptReview = command { + count += 1 + } + } } private func cancelPrepareReviewRestartWaiter(id: UUID) { @@ -526,6 +863,20 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { restartPreparedReviewWaiters.removeValue(forKey: id)?.resume() } + private func cancelCleanupReviewWaiter( + attemptID: ReviewAttemptID, + waiterID: UUID + ) { + cleanupReviewWaiters[attemptID]?.removeValue(forKey: waiterID)?.resume() + if cleanupReviewWaiters[attemptID]?.isEmpty == true { + cleanupReviewWaiters.removeValue(forKey: attemptID) + } + } + + private func cancelRetainedCleanupWaiter(id: UUID) { + retainedCleanupWaiters.removeValue(forKey: id)?.resume() + } + } @MainActor @@ -540,18 +891,18 @@ package final class StoreSnapshotProbe { let reviewRuns = store.reviewRuns .sorted { lhs, rhs in if lhs.sortOrder == rhs.sortOrder { - return lhs.id < rhs.id + return lhs.id.rawValue < rhs.id.rawValue } return lhs.sortOrder > rhs.sortOrder } .map { runRecord in let runtimeState = store.runtimeReviewRunState(runID: runRecord.id) return StoreRunSnapshot( - runID: runRecord.id, - status: runRecord.core.lifecycle.status, - summary: runRecord.core.lifecycleMessage, - run: runRecord.core.run, - activeRun: runtimeState.activeRun, + runID: runRecord.id.rawValue, + status: runRecord.core.status, + summary: runRecord.presentation.lifecycle.testingSummary, + attempt: runRecord.core.attempt, + activeAttempt: runtimeState.activeAttempt, cancellationRequested: runRecord.cancellationRequested ) } @@ -574,7 +925,7 @@ package final class StoreSnapshotProbe { timeout: Duration = .seconds(2) ) async -> StoreSnapshot? { await waitUntil(timeout: timeout) { snapshot in - snapshot.run(runID)?.activeRun?.attemptID == attemptID + snapshot.run(runID)?.activeAttempt?.attemptID.rawValue == attemptID } } @@ -612,11 +963,36 @@ package struct StoreRunSnapshot: Sendable { package var runID: String package var status: ReviewRunState package var summary: String - package var run: ReviewRunCore.Run - package var activeRun: CodexReviewBackendModel.Review.Run? + package var attempt: ReviewAttempt? + package var activeAttempt: ReviewAttempt? package var cancellationRequested: Bool } +private extension ReviewLifecyclePresentation { + var testingSummary: String { + switch self { + case .queued: + "Queued." + case .starting: + "Starting review." + case .running: + "Review started." + case .waitingForNetwork: + "Network unavailable; waiting to reconnect." + case .preparingRestart: + "Preparing review restart." + case .restarting: + "Network restored; restarting review." + case .cancelling(let cancellation), .cancelled(let cancellation): + cancellation.message + case .succeeded: + "Review completed." + case .failed(let failure): + failure.message + } + } +} + @MainActor package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { package let reviewBackend: FakeCodexReviewBackend @@ -646,7 +1022,7 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { store.transitionToRunning(serverURL: nil) } - package func stop(store _: CodexReviewStore) async { + package func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { isActive = false } @@ -669,28 +1045,21 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { auth.selectPersistedAccount(snapshot.activeAccountID?.rawValue) auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } } - package func signIn(auth: CodexReviewAuthModel) async { - do { - let challenge = try await reviewBackend.startLogin(.init()) - auth.updatePhase( - .signingIn( - .init( - title: "Sign in to Codex", - detail: "Complete sign in in your browser, then return to ReviewMonitor.", - browserURL: challenge.verificationURL?.absoluteString, - userCode: challenge.userCode - ))) - } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) - } + package func signIn(auth: CodexReviewAuthModel) async throws { + auth.updatePhase(.signingIn(.init( + title: "Sign in to Codex", + detail: "Complete sign in in your browser, then return to ReviewMonitor.", + browserURL: nil, + userCode: nil + ))) } - package func addAccount(auth: CodexReviewAuthModel) async { - await signIn(auth: auth) + package func addAccount(auth: CodexReviewAuthModel) async throws { + try await signIn(auth: auth) } package func cancelAuthentication(auth: CodexReviewAuthModel) async { @@ -756,16 +1125,16 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { } package func interruptReview( - _ run: CodexReviewBackendModel.Review.Run, + _ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason ) async throws { - try await reviewBackend.interruptReview(run, reason: reason) + try await reviewBackend.interruptReview(attempt, reason: reason) } package func prepareReviewRestart( - _ run: CodexReviewBackendModel.Review.Run + _ attempt: ReviewAttempt ) async throws -> CodexReviewBackendModel.Review.RestartToken { - try await reviewBackend.prepareReviewRestart(run) + try await reviewBackend.prepareReviewRestart(attempt) } package func restartPreparedReview( @@ -775,8 +1144,24 @@ package final class TestingCodexReviewStoreBackend: CodexReviewStoreBackend { try await reviewBackend.restartPreparedReview(token, request: request) } - package func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - await reviewBackend.cleanupReview(run) + package func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + await reviewBackend.discardPreparedReviewRestart(token) + } + + package func cleanupReview(_ attempt: ReviewAttempt) async { + await reviewBackend.cleanupReview(attempt) + } + + package func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + await reviewBackend.cleanupRetainedReviews( + attempts, + additionalThreadIDs: additionalThreadIDs + ) } package func refreshSettings() async throws -> CodexReviewSettings.Snapshot { diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 4ca0eeef..02355a95 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -229,12 +229,6 @@ enum ReviewMonitorCommandOutputDisplayDocument { return primary } - let durationMs = - primary.durationMs ?? fallback.durationMs - ?? commandDurationMs( - startedAt: primary.startedAt ?? fallback.startedAt, - completedAt: primary.completedAt ?? fallback.completedAt - ) let title = primary.title ?? fallback.title let status = primary.status ?? fallback.status let detail = primary.detail ?? fallback.detail @@ -244,6 +238,9 @@ enum ReviewMonitorCommandOutputDisplayDocument { let exitCode = primary.exitCode ?? fallback.exitCode let startedAt = primary.startedAt ?? fallback.startedAt let completedAt = primary.completedAt ?? fallback.completedAt + let durationMs = + commandDurationMs(startedAt: startedAt, completedAt: completedAt) + ?? primary.durationMs ?? fallback.durationMs let commandActions = primary.commandActions ?? fallback.commandActions let commandStatus = primary.commandStatus ?? fallback.commandStatus let namespace = primary.namespace ?? fallback.namespace @@ -618,11 +615,11 @@ enum ReviewMonitorCommandOutputDisplayDocument { durationMs = commandDurationMs(startedAt: startedAt, completedAt: currentDate) } else { durationMs = - metadata?.durationMs - ?? commandDurationMs( + commandDurationMs( startedAt: metadata?.startedAt, completedAt: metadata?.completedAt ) + ?? metadata?.durationMs } guard let durationMs else { return nil @@ -683,6 +680,11 @@ enum ReviewMonitorCommandOutputDisplayDocument { if normalizedStatus == "cancelled" || normalizedStatus == "canceled" { return "Cancelled" } + if normalizedStatus == "inprogress" || normalizedStatus == "in_progress" + || normalizedStatus == "started" || normalizedStatus == "running" + { + return "running" + } return rawStatus?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index 2aa11aad..c907870f 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -1,9 +1,17 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation +import OSLog + +private let projectionLogger = Logger( + subsystem: "CodexReviewKit", + category: "chat-log-projection" +) @MainActor struct ReviewMonitorCodexChatLogProjection { private var documentProjection = ReviewMonitorLogDocumentProjection() + private var reportedMissingRolloutTargetSourceIDs: Set = [] var currentDocument: ReviewMonitorLog.Document { documentProjection.currentDocument @@ -11,6 +19,7 @@ struct ReviewMonitorCodexChatLogProjection { mutating func reset() { documentProjection.reset() + reportedMissingRolloutTargetSourceIDs.removeAll(keepingCapacity: false) } mutating func render( @@ -78,6 +87,64 @@ struct ReviewMonitorCodexChatLogProjection { ) } + mutating func render( + projectedBlocks: [ReviewMonitorLogProjectedBlock] + ) -> ReviewMonitorLog.Document? { + guard projectedBlocks.isEmpty == false else { + documentProjection.reset() + return nil + } + return documentProjection.render(projectedBlocks: projectedBlocks) + } + + func projectedBlocks( + from item: CodexThreadItem, + turnID: CodexTurnID, + turnStatus: CodexTurnStatus, + chatCreatedAt: Date?, + chatUpdatedAt: Date?, + suppressUserMessage: Bool, + suppressRolloutCompanion: Bool + ) -> [ReviewMonitorLogProjectedBlock] { + guard suppressRolloutCompanion == false else { + return [] + } + return projectedBlocks( + from: CodexThreadSnapshotLogItem( + item: item, + turnID: turnID, + turnStatus: turnStatus + ), + turnStatus: turnStatus, + chatCreatedAt: chatCreatedAt, + chatUpdatedAt: chatUpdatedAt, + suppressUserMessage: suppressUserMessage + ) + } + + func presentationFacts( + for item: CodexThreadItem, + turnID: CodexTurnID, + turnStatus: CodexTurnStatus + ) -> ReviewItemPresentationFacts { + Self.presentationFacts( + CodexThreadSnapshotLogItem( + item: item, + turnID: turnID, + turnStatus: turnStatus + ) + ) + } + + func dependsOnTurnStatus(_ item: CodexThreadItem) -> Bool { + switch item.content { + case .command, .fileChange, .contextCompaction, .diagnostic, .log: + true + case .message, .plan, .reasoning, .toolCall, .unknown: + false + } + } + private mutating func render( items: [Item], turnStatus: CodexTurnStatus?, @@ -88,37 +155,26 @@ struct ReviewMonitorCodexChatLogProjection { documentProjection.reset() return nil } - let suppressUserMessages = items.contains { item in - item.kind == .enteredReviewMode || item.kind == .exitedReviewMode - } - let reviewOutputKeys = Set(items.compactMap(Self.reviewOutputKey)) - var emittedReviewOutputKeys = Set() - var pendingReasoningMirrors = PendingReasoningMirrors() + let presentationFacts = items.map(Self.presentationFacts) + let rolloutPolicy = ReviewRolloutPresentationPolicy().evaluate(presentationFacts) + let turnPolicy = ReviewTurnPresentationPolicy( + items: presentationFacts, + additionalHiddenTurnIDs: rolloutPolicy.hiddenUserMessageTurnIDs + ) + reportMissingRolloutTargets(rolloutPolicy.missingTargetSourceIDs) let blocks = items.flatMap { item in - if let reviewOutputKey = Self.reviewOutputKey(for: item) { - guard emittedReviewOutputKeys.insert(reviewOutputKey).inserted else { - return [] as [ReviewMonitorLogProjectedBlock] - } - } - if let reasoningOutputKey = Self.reasoningOutputKey(for: item) { - guard pendingReasoningMirrors.shouldRender(reasoningOutputKey) else { - return [] as [ReviewMonitorLogProjectedBlock] - } + if rolloutPolicy.suppressedCompanionSourceIDs.contains(item.sourceID) { + return [] as [ReviewMonitorLogProjectedBlock] } return projectedBlocks( from: item, turnStatus: turnStatus, chatCreatedAt: chatCreatedAt, chatUpdatedAt: chatUpdatedAt, - suppressUserMessages: suppressUserMessages, - reviewOutputKeys: reviewOutputKeys + suppressUserMessage: turnPolicy.hidesUserMessage(in: item.projectionTurnID) ) } - guard blocks.isEmpty == false else { - documentProjection.reset() - return nil - } - return documentProjection.render(projectedBlocks: blocks) + return render(projectedBlocks: blocks) } private func projectedBlocks( @@ -126,18 +182,11 @@ struct ReviewMonitorCodexChatLogProjection { turnStatus: CodexTurnStatus?, chatCreatedAt: Date?, chatUpdatedAt: Date?, - suppressUserMessages: Bool, - reviewOutputKeys: Set + suppressUserMessage: Bool ) -> [ReviewMonitorLogProjectedBlock] { switch item.content { case .message(let message): - guard suppressUserMessages == false || message.role != .user else { - return [] - } - if message.role == .assistant, - let reviewOutputKey = Self.reviewOutputKey(for: item, text: message.text), - reviewOutputKeys.contains(reviewOutputKey) - { + guard suppressUserMessage == false || message.role != .user else { return [] } return [ @@ -255,71 +304,26 @@ struct ReviewMonitorCodexChatLogProjection { } } - private static func reviewOutputKey(for item: Item) -> ReviewOutputKey? { - guard item.kind == .exitedReviewMode else { - return nil - } - switch item.content { - case .message(let message): - return reviewOutputKey(for: item, text: message.text) - case .diagnostic(let text), .log(let text): - return reviewOutputKey(for: item, text: text) - case .unknown(let raw): - return raw.text.flatMap { reviewOutputKey(for: item, text: $0) } - case .plan, .reasoning, .command, .fileChange, .toolCall, .contextCompaction: - return nil - } - } - - private static func reviewOutputKey( - for item: Item, - text: String - ) -> ReviewOutputKey? { - guard let normalizedText = normalizedReviewOutputText(text).nilIfEmpty else { - return nil - } - return ReviewOutputKey(scopeID: reviewOutputScopeID(for: item), text: normalizedText) - } - - private static func reviewOutputScopeID(for item: Item) -> String { - item.projectionTurnID?.rawValue ?? item.sourceID - } - - private static func reasoningOutputKey(for item: Item) -> ReasoningOutputKey? { - guard item.kind == .reasoning else { - return nil - } - guard case .reasoning(let reasoning) = item.content, - let normalizedText = normalizedReasoningOutputText(reasoning.text).nilIfEmpty - else { - return nil - } - return ReasoningOutputKey( - scopeID: reasoningOutputScopeID(for: item), - text: normalizedText, - payloadKind: rawPayloadKind(from: item.rawPayload) + private static func presentationFacts( + _ item: Item + ) -> ReviewItemPresentationFacts { + .init( + sourceID: item.sourceID, + turnID: item.projectionTurnID, + kind: item.kind, + origin: item.origin, + semanticRelation: item.semanticRelation, + displayText: item.content.reviewRolloutDisplayText ) } - private static func reasoningOutputScopeID(for item: Item) -> String { - item.projectionTurnID?.rawValue ?? "item:\(item.sourceID)" - } - - private static func normalizedReviewOutputText(_ text: String) -> String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func normalizedReasoningOutputText(_ text: String) -> String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func rawPayloadKind(from data: Data?) -> String? { - guard let data, - let payload = try? JSONDecoder().decode(RawPayloadKind.self, from: data) - else { - return nil + mutating func reportMissingRolloutTargets(_ sourceIDs: Set) { + for sourceID in sourceIDs + where reportedMissingRolloutTargetSourceIDs.insert(sourceID).inserted { + projectionLogger.error( + "Review rollout companion has no exited-review target in its review sequence: \(sourceID, privacy: .public)" + ) } - return payload.kindValue } private func unknownText(title: String, detail: String?) -> String { @@ -592,87 +596,19 @@ struct ReviewMonitorCodexChatLogProjection { { return turnStatus } - return itemStatus ?? turnStatus ?? .running + return itemStatus ?? turnStatus ?? .inProgress } private func terminalStatus(_ status: CodexTurnStatus) -> Bool { switch status { - case .running, .unknown: + case .inProgress, .unknown: return false - case .completed, .failed, .interrupted, .cancelled: + case .completed, .failed, .interrupted: return true } } } -private struct ReviewOutputKey: Hashable { - var scopeID: String - var text: String -} - -private struct ReasoningOutputKey: Hashable { - var scopeID: String - var text: String - var payloadKind: String? -} - -// Codex delivers one logical reasoning entry through two payload kinds -// (`agent_reasoning` event and `reasoning` item), and the mirror may arrive -// after unrelated items such as commands. Each rendered entry therefore -// consumes at most one later mirror with the counterpart payload kind, which -// suppresses late mirrors without dropping legitimately repeated reasoning. -private struct PendingReasoningMirrors { - private struct EntryKey: Hashable { - var scopeID: String - var text: String - } - - private static let mirrorPayloadKinds: Set = ["agent_reasoning", "reasoning"] - - private var pendingPayloadKindsByKey: [EntryKey: [String]] = [:] - - mutating func shouldRender(_ key: ReasoningOutputKey) -> Bool { - guard let payloadKind = key.payloadKind, - Self.mirrorPayloadKinds.contains(payloadKind) - else { - return true - } - let entryKey = EntryKey(scopeID: key.scopeID, text: key.text) - var pending = pendingPayloadKindsByKey[entryKey] ?? [] - if let mirrorIndex = pending.firstIndex(where: { $0 != payloadKind }) { - pending.remove(at: mirrorIndex) - pendingPayloadKindsByKey[entryKey] = pending.isEmpty ? nil : pending - return false - } - pending.append(payloadKind) - pendingPayloadKindsByKey[entryKey] = pending - return true - } -} - -private struct RawPayloadKind: Decodable { - struct NestedItem: Decodable { - var type: String? - var kind: String? - - var kindValue: String? { - type ?? kind - } - } - - var type: String? - var kind: String? - var item: NestedItem? - var payload: NestedItem? - - // A nested item/payload kind identifies the item itself; a top-level - // type on the same wrapper is the event envelope kind, so the nested - // kind wins when both are present. - var kindValue: String? { - item?.kindValue ?? payload?.kindValue ?? type ?? kind - } -} - @MainActor private protocol CodexChatLogProjectionItem { var itemID: String { get } @@ -681,8 +617,9 @@ private protocol CodexChatLogProjectionItem { var projectionTurnStatus: CodexTurnStatus? { get } var kind: CodexThreadItem.Kind { get } var content: CodexThreadItem.Content { get } + var origin: CodexThreadItem.Origin { get } + var semanticRelation: CodexThreadItem.SemanticRelation? { get } var itemStatus: CodexTurnStatus? { get } - var rawPayload: Data? { get } } extension CodexItem: CodexChatLogProjectionItem { @@ -732,13 +669,18 @@ private struct CodexChatModelLogItem: CodexChatLogProjectionItem { item.content } + var origin: CodexThreadItem.Origin { + item.origin + } + + var semanticRelation: CodexThreadItem.SemanticRelation? { + item.semanticRelation + } + var itemStatus: CodexTurnStatus? { item.content.reviewMonitorLogItemStatus } - var rawPayload: Data? { - item.rawPayload - } } @MainActor @@ -774,27 +716,37 @@ private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem { item.content } - var itemStatus: CodexTurnStatus? { - item.content.reviewMonitorLogItemStatus + var origin: CodexThreadItem.Origin { + item.origin } - var rawPayload: Data? { - item.rawPayload + var semanticRelation: CodexThreadItem.SemanticRelation? { + item.semanticRelation + } + + var itemStatus: CodexTurnStatus? { + item.content.reviewMonitorLogItemStatus } private var semanticItemID: String { - switch item.kind { - case .enteredReviewMode: - "review-marker:enteredReviewMode" - case .exitedReviewMode: - "review-marker:exitedReviewMode" - default: - item.id - } + "\(item.kind.rawValue):\(item.id)" } } private extension CodexThreadItem.Content { + var reviewRolloutDisplayText: String? { + switch self { + case .message(let message): + message.text + case .diagnostic(let text), .log(let text): + text + case .unknown(let raw): + raw.text + case .plan, .reasoning, .command, .fileChange, .toolCall, .contextCompaction: + nil + } + } + var reviewMonitorLogItemStatus: CodexTurnStatus? { switch self { case .command(let command): diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 0f25164d..3b549a13 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation @MainActor @@ -30,69 +31,500 @@ enum ReviewMonitorLogSourceChange: Equatable { @MainActor struct ReviewMonitorCodexChatLogSourceProjection { + private struct Cursor: Equatable { + var generation: UInt64 + var sequence: UInt64 + } + private var logProjection = ReviewMonitorCodexChatLogProjection() + private var snapshot: CodexChatObservationSnapshot? + private var cursor: Cursor? private var hasLogDocument = false + private var cachedPresentationState: ThreadPresentationState? + private var projectedBlocksByLocator: + [CodexChatItemLocator: [ReviewMonitorLogProjectedBlock]] = [:] mutating func reset() { logProjection.reset() + snapshot = nil + cursor = nil hasLogDocument = false + cachedPresentationState = nil + projectedBlocksByLocator.removeAll(keepingCapacity: false) } - mutating func applyBaseline( - from chat: CodexChat, - chatCreatedAt: Date?, - chatUpdatedAt: Date? + mutating func apply( + _ event: CodexChatObservationEvent ) -> ReviewMonitorLogSourceChange? { - renderChat( - from: chat, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: false - ) ?? .clear + switch event.payload { + case .snapshot(let snapshot, let reason): + validateSnapshotCursor(event, reason: reason) + self.snapshot = snapshot + replaceProjectionCache(with: snapshot) + cursor = .init(generation: event.generation, sequence: event.sequence) + return renderCachedProjection(allowIncrementalUpdate: false) + case .update(let update): + validateUpdateCursor(event) + apply(update) + cursor = .init(generation: event.generation, sequence: event.sequence) + return renderCachedProjection(allowIncrementalUpdate: hasLogDocument) + } } - mutating func apply( - _ update: CodexChatUpdate, - in chat: CodexChat, - chatCreatedAt: Date?, - chatUpdatedAt: Date? - ) -> ReviewMonitorLogSourceChange? { - let allowsIncrementalUpdate: Bool + private mutating func validateSnapshotCursor( + _ event: CodexChatObservationEvent, + reason: CodexChatSnapshotReason + ) { + guard let cursor else { + return + } + if event.generation > cursor.generation { + precondition(event.sequence == 0) + precondition(reason == .generationRestart) + return + } + precondition(event.generation == cursor.generation) + precondition(event.sequence >= cursor.sequence) + } + + private func validateUpdateCursor(_ event: CodexChatObservationEvent) { + guard let cursor else { + preconditionFailure("A chat observation update requires an initial snapshot.") + } + precondition(event.generation == cursor.generation) + precondition(event.sequence == cursor.sequence &+ 1) + } + + private mutating func apply(_ update: CodexChatUpdate) { + guard var snapshot else { + preconditionFailure("A chat observation update requires projection state.") + } + let previousSnapshot = snapshot + var turns = snapshot.thread.turns ?? [] + var explicitlyChanged: Set = [] + var removed: Set = [] + var statusDependent: Set = [] + var presentationChanged = false switch update { - case .resynchronized: - allowsIncrementalUpdate = hasLogDocument - case .turnInserted, - .turnUpdated, - .statusChanged, - .phaseChanged, - .itemInserted, - .itemUpdated, - .itemRemoved, - .itemTextAppended: - allowsIncrementalUpdate = hasLogDocument - } - return renderChat( - from: chat, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: allowsIncrementalUpdate + case .turnInserted(let turn, let index): + precondition(turns.contains { $0.id == turn.id } == false) + precondition(turns.indices.contains(index) || index == turns.endIndex) + turns.insert(turn, at: index) + presentationChanged = cachedPresentationState?.hasExitedReviewMarker == true + || turn.items.contains(where: itemAffectsPresentation) + explicitlyChanged.formUnion(turn.items.map { + locator(for: $0, turnID: turn.id) + }) + case .turnUpdated(var turn, let index): + let previousIndex = requiredTurnIndex(turn.id, in: turns) + precondition( + itemsAreSemanticallyEqual(turns[previousIndex].items, turn.items), + "A turnUpdated event changed semantic item ownership." + ) + let previous = turns[previousIndex] + turn.items = turns[previousIndex].items + turns.remove(at: previousIndex) + precondition(turns.indices.contains(index) || index == turns.endIndex) + turns.insert(turn, at: index) + presentationChanged = previousIndex != index + && turn.items.contains(where: itemAffectsPresentation) + if previous.status != turn.status { + statusDependent.formUnion(turn.items.compactMap { item in + logProjection.dependsOnTurnStatus(item) + ? locator(for: item, turnID: turn.id) + : nil + }) + } + case .turnRemoved(let id): + let index = requiredUniqueIndex(in: turns) { $0.id == id } + let removedTurn = turns.remove(at: index) + presentationChanged = cachedPresentationState?.hasExitedReviewMarker == true + || removedTurn.items.contains(where: itemAffectsPresentation) + for item in removedTurn.items { + removed.insert(locator(for: item, turnID: removedTurn.id)) + } + case .itemInserted(let item, let turnID, let index): + let turnIndex = requiredTurnIndex(turnID, in: turns) + precondition(turns[turnIndex].items.contains { + itemMatches($0, id: item.id, kind: item.kind) + } == false) + precondition( + turns[turnIndex].items.indices.contains(index) + || index == turns[turnIndex].items.endIndex + ) + turns[turnIndex].items.insert(item, at: index) + presentationChanged = itemAffectsPresentation(item) + explicitlyChanged.insert(locator(for: item, turnID: turnID)) + case .itemUpdated(let item, let turnID, let index): + let turnIndex = requiredTurnIndex(turnID, in: turns) + let previousIndex = requiredUniqueIndex(in: turns[turnIndex].items) { + itemMatches($0, id: item.id, kind: item.kind) + } + let previousItem = turns[turnIndex].items.remove(at: previousIndex) + precondition( + turns[turnIndex].items.indices.contains(index) + || index == turns[turnIndex].items.endIndex + ) + turns[turnIndex].items.insert(item, at: index) + let previousLocator = locator(for: previousItem, turnID: turnID) + let currentLocator = locator(for: item, turnID: turnID) + presentationChanged = + itemAffectsPresentation(previousItem) || itemAffectsPresentation(item) + explicitlyChanged.insert(currentLocator) + if previousLocator != currentLocator { + removed.insert(previousLocator) + } + case .itemRemoved(let locator): + let turnIndex = requiredTurnIndex(locator.turnID, in: turns) + let itemIndex = requiredUniqueIndex(in: turns[turnIndex].items) { + itemMatches($0, id: locator.id, kind: locator.kind) + } + let removedItem = turns[turnIndex].items.remove(at: itemIndex) + presentationChanged = itemAffectsPresentation(removedItem) + removed.insert(locator) + case .itemTextAppended(let locator, let delta): + let turnIndex = requiredTurnIndex(locator.turnID, in: turns) + let itemIndex = requiredUniqueIndex(in: turns[turnIndex].items) { + itemMatches($0, id: locator.id, kind: locator.kind) + } + presentationChanged = itemAffectsPresentation(turns[turnIndex].items[itemIndex]) + append(delta, to: &turns[turnIndex].items[itemIndex]) + explicitlyChanged.insert(locator) + case .statusChanged(let status): + snapshot.thread.status = status + case .phaseChanged(let phase): + let affectedTurnIDs = Set([snapshot.phase.turnID, phase.turnID].compactMap { $0 }) + snapshot.phase = phase + for turnID in affectedTurnIDs { + let turnIndex = requiredTurnIndex(turnID, in: turns) + statusDependent.formUnion(turns[turnIndex].items.compactMap { item in + logProjection.dependsOnTurnStatus(item) + ? locator(for: item, turnID: turnID) + : nil + }) + } + } + snapshot.thread.turns = turns + self.snapshot = snapshot + reconcileProjection( + previous: previousSnapshot, + current: snapshot, + explicitlyChanged: explicitlyChanged.union(statusDependent), + removed: removed, + presentationChanged: presentationChanged + ) + } + + private mutating func replaceProjectionCache( + with snapshot: CodexChatObservationSnapshot + ) { + projectedBlocksByLocator.removeAll(keepingCapacity: true) + let presentation = presentationState(for: snapshot, reportMissing: true) + cachedPresentationState = presentation + for turn in snapshot.thread.turns ?? [] { + projectWholeTurn( + turn, + phase: snapshot.phase, + presentation: presentation + ) + } + } + + private mutating func projectWholeTurn( + _ turn: CodexTurnSnapshot, + phase: CodexChatPhase, + presentation: ThreadPresentationState + ) { + var seen: Set = [] + for item in turn.items { + let locator = locator(for: item, turnID: turn.id) + precondition(seen.insert(locator).inserted) + precondition(projectedBlocksByLocator[locator] == nil) + reproject(item, in: turn, phase: phase, presentation: presentation) + } + } + + private mutating func reconcileProjection( + previous: CodexChatObservationSnapshot, + current: CodexChatObservationSnapshot, + explicitlyChanged: Set, + removed: Set, + presentationChanged: Bool + ) { + precondition(previous.thread.id == current.thread.id) + guard let previousPresentation = cachedPresentationState else { + preconditionFailure("Projection cache requires presentation state.") + } + let currentPresentation: ThreadPresentationState + if presentationChanged { + currentPresentation = presentationState(for: current, reportMissing: true) + cachedPresentationState = currentPresentation + } else { + currentPresentation = previousPresentation + } + + for locator in removed { + precondition(projectedBlocksByLocator.removeValue(forKey: locator) != nil) + } + + var locatorsToReproject = explicitlyChanged + if presentationChanged { + for turn in current.thread.turns ?? [] { + let userMessageVisibilityChanged = + previousPresentation.hidesUserMessage(in: turn.id) + != currentPresentation.hidesUserMessage(in: turn.id) + for item in turn.items { + let locator = locator(for: item, turnID: turn.id) + if userMessageVisibilityChanged && item.isUserMessage { + locatorsToReproject.insert(locator) + } + let facts = logProjection.presentationFacts( + for: item, + turnID: turn.id, + turnStatus: projectedStatus(for: turn, phase: current.phase) + ) + let wasSuppressed = previousPresentation.suppressedRolloutSourceIDs + .contains(facts.sourceID) + let isSuppressed = currentPresentation.suppressedRolloutSourceIDs + .contains(facts.sourceID) + if wasSuppressed != isSuppressed { + locatorsToReproject.insert(locator) + } + } + } + } + + for locator in locatorsToReproject { + let turns = current.thread.turns ?? [] + let turnIndex = requiredTurnIndex(locator.turnID, in: turns) + let turn = turns[turnIndex] + let itemIndex = requiredUniqueIndex(in: turn.items) { + itemMatches($0, id: locator.id, kind: locator.kind) + } + reproject( + turn.items[itemIndex], + in: turn, + phase: current.phase, + presentation: currentPresentation + ) + } + } + + private mutating func reproject( + _ item: CodexThreadItem, + in turn: CodexTurnSnapshot, + phase: CodexChatPhase, + presentation: ThreadPresentationState + ) { + let status = projectedStatus(for: turn, phase: phase) + let facts = logProjection.presentationFacts( + for: item, + turnID: turn.id, + turnStatus: status + ) + projectedBlocksByLocator[locator(for: item, turnID: turn.id)] = + logProjection.projectedBlocks( + from: item, + turnID: turn.id, + turnStatus: status, + chatCreatedAt: snapshot?.thread.createdAt, + chatUpdatedAt: snapshot?.thread.updatedAt, + suppressUserMessage: presentation.hidesUserMessage(in: turn.id), + suppressRolloutCompanion: + presentation.suppressedRolloutSourceIDs.contains(facts.sourceID) + ) + } + + private mutating func presentationState( + for snapshot: CodexChatObservationSnapshot, + reportMissing: Bool + ) -> ThreadPresentationState { + let facts = (snapshot.thread.turns ?? []).flatMap { turn in + let status = projectedStatus(for: turn, phase: snapshot.phase) + return turn.items.map { + logProjection.presentationFacts( + for: $0, + turnID: turn.id, + turnStatus: status + ) + } + } + let rollout = ReviewRolloutPresentationPolicy().evaluate(facts) + if reportMissing { + logProjection.reportMissingRolloutTargets(rollout.missingTargetSourceIDs) + } + return .init( + userMessagePolicy: ReviewTurnPresentationPolicy( + items: facts, + additionalHiddenTurnIDs: rollout.hiddenUserMessageTurnIDs + ), + suppressedRolloutSourceIDs: rollout.suppressedCompanionSourceIDs, + hasExitedReviewMarker: facts.contains { $0.kind == .exitedReviewMode } ) } - private mutating func renderChat( - from chat: CodexChat, - chatCreatedAt: Date?, - chatUpdatedAt: Date?, + private func projectedStatus( + for turn: CodexTurnSnapshot, + phase: CodexChatPhase + ) -> CodexTurnStatus { + guard phase.turnID == turn.id else { + return turn.status + } + switch phase { + case .running: + return .inProgress + case .terminal(_, let disposition): + switch disposition { + case .completed: + return .completed + case .interrupted: + return .interrupted + case .failed: + return .failed + case .invalid(let rawStatus): + return .unknown(rawValue: rawStatus) + } + case .idle, .loading, .failed: + return turn.status + } + } + + private func locator( + for item: CodexThreadItem, + turnID: CodexTurnID + ) -> CodexChatItemLocator { + .init(item: item, turnID: turnID) + } + + private func requiredTurnIndex( + _ id: CodexTurnID, + in turns: [CodexTurnSnapshot] + ) -> Int { + requiredUniqueIndex(in: turns) { $0.id == id } + } + + private func requiredUniqueIndex( + in values: [Value], + where predicate: (Value) -> Bool + ) -> Int { + let indices = values.indices.filter { predicate(values[$0]) } + precondition(indices.count == 1) + return indices[0] + } + + private func itemMatches( + _ item: CodexThreadItem, + id: String, + kind: CodexThreadItem.Kind + ) -> Bool { + item.id == id && item.kind == kind + } + + private func itemAffectsPresentation(_ item: CodexThreadItem) -> Bool { + if item.kind == .enteredReviewMode + || item.kind == .exitedReviewMode + || item.origin == .reviewRolloutAssistant + { + return true + } + guard cachedPresentationState?.hasExitedReviewMarker == true else { + return false + } + return item.kind == .userMessage || item.kind == .agentMessage + } + + private func itemsAreSemanticallyEqual( + _ lhs: [CodexThreadItem], + _ rhs: [CodexThreadItem] + ) -> Bool { + guard lhs.count == rhs.count else { + return false + } + return zip(lhs, rhs).allSatisfy { lhs, rhs in + lhs.id == rhs.id + && lhs.kind == rhs.kind + && lhs.origin == rhs.origin + && lhs.semanticRelation == rhs.semanticRelation + && contentsAreSemanticallyEqual(lhs.content, rhs.content) + } + } + + private func contentsAreSemanticallyEqual( + _ lhs: CodexThreadItem.Content, + _ rhs: CodexThreadItem.Content + ) -> Bool { + if case .reasoning(let lhsReasoning) = lhs, + case .reasoning(let rhsReasoning) = rhs { + return lhsReasoning.text == rhsReasoning.text + } + return lhs == rhs + } + + private func append(_ delta: String, to item: inout CodexThreadItem) { + switch item.content { + case .message(var message): + message.text += delta + item.content = .message(message) + case .plan(let text): + item.content = .plan(text + delta) + case .reasoning(var reasoning): + if reasoning.summary.isEmpty { + append(delta, to: &reasoning.content) + } else { + append(delta, to: &reasoning.summary) + } + item.content = .reasoning(reasoning) + case .command(var command): + command.output = (command.output ?? "") + delta + item.content = .command(command) + case .fileChange(var fileChange): + fileChange.output = (fileChange.output ?? "") + delta + item.content = .fileChange(fileChange) + case .toolCall(var toolCall): + toolCall.result = (toolCall.result ?? "") + delta + item.content = .toolCall(toolCall) + case .contextCompaction(let text): + item.content = .contextCompaction((text ?? "") + delta) + case .diagnostic(let text): + item.content = .diagnostic(text + delta) + case .log(let text): + item.content = .log(text + delta) + case .unknown(var raw): + raw.text = (raw.text ?? "") + delta + item.content = .unknown(raw) + } + } + + private func append(_ delta: String, to fragments: inout [String]) { + if fragments.isEmpty { + fragments = [delta] + } else { + fragments[fragments.index(before: fragments.endIndex)] += delta + } + } + + private mutating func renderCachedProjection( allowIncrementalUpdate: Bool ) -> ReviewMonitorLogSourceChange? { - guard - let document = logProjection.render( - from: chat, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) - else { - return clearIfNeeded() + guard let snapshot else { + preconditionFailure("Projection cache requires an observation snapshot.") + } + let projectedBlocks = (snapshot.thread.turns ?? []).flatMap { turn in + turn.items.flatMap { item -> [ReviewMonitorLogProjectedBlock] in + let locator = locator(for: item, turnID: turn.id) + guard let blocks = projectedBlocksByLocator[locator] else { + preconditionFailure("Projection cache is missing item \(locator.id).") + } + return blocks + } + } + guard let document = logProjection.render(projectedBlocks: projectedBlocks) else { + if allowIncrementalUpdate { + return clearIfNeeded() + } + logProjection.reset() + hasLogDocument = false + return .clear } defer { hasLogDocument = true @@ -108,4 +540,23 @@ struct ReviewMonitorCodexChatLogSourceProjection { hasLogDocument = false return .clear } + + private struct ThreadPresentationState { + var userMessagePolicy: ReviewTurnPresentationPolicy + var suppressedRolloutSourceIDs: Set + var hasExitedReviewMarker: Bool + + func hidesUserMessage(in turnID: CodexTurnID) -> Bool { + userMessagePolicy.hidesUserMessage(in: turnID) + } + } +} + +private extension CodexThreadItem { + var isUserMessage: Bool { + guard case .message(let message) = content else { + return false + } + return message.role == .user + } } diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index 804bd71e..0796b54c 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -1,5 +1,6 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import OSLog private let logger = Logger(subsystem: "CodexReviewKit", category: "chat-log-target") @@ -25,7 +26,6 @@ package final class ReviewMonitorCodexChatLogTarget { private let logScrollView = ReviewMonitorLogScrollView() private var logRenderer = ReviewMonitorLogRenderer() - private var selectedChatObservation: CodexChatObservation? private var selectedChatLogTask: Task? private var boundModelContext: CodexModelContext? private var boundChatID: CodexThreadID? @@ -48,7 +48,6 @@ package final class ReviewMonitorCodexChatLogTarget { package init() {} isolated deinit { - selectedChatObservation?.cancel() selectedChatLogTask?.cancel() pendingLogSourceChangeTask?.cancel() logRenderTask?.cancel() @@ -70,7 +69,6 @@ package final class ReviewMonitorCodexChatLogTarget { logger.debug( "Binding Codex chat log chatID=\(selectedChatID.rawValue, privacy: .public) switchingChat=\(isSwitchingRenderedChat, privacy: .public)" ) - cancelSelectedChatObservation() cancelPendingLogSourceChange() resetLogRenderer() logProjection.reset() @@ -88,10 +86,7 @@ package final class ReviewMonitorCodexChatLogTarget { @discardableResult package func clear() -> Bool { cacheBoundLogScrollTarget() - // Keep the cancelled task referenced so the next bind can await its - // teardown before re-observing. selectedChatLogTask?.cancel() - cancelSelectedChatObservation() cancelPendingLogSourceChange() boundChatID = nil boundModelContext = nil @@ -154,54 +149,35 @@ package final class ReviewMonitorCodexChatLogTarget { target: LogRenderTarget, initialRestorationTarget: ReviewMonitorLogScrollView.ScrollRestorationTarget ) { - let previousTask = selectedChatLogTask - previousTask?.cancel() + selectedChatLogTask?.cancel() selectedChatLogTask = Task { @MainActor [weak self, weak chat, weak modelContext] in - // Wait for the previous observation task to unwind so its possibly - // in-flight observation registration is released before observing - // again; otherwise observe() can throw chatObservationAlreadyActive. - await previousTask?.value guard Task.isCancelled == false, let chat, let modelContext else { return } do { let observation = try await modelContext.observe(chat) guard Task.isCancelled == false, - let self, - self.boundChat === chat, - self.isCurrentLogRenderTarget(target) + self?.boundChat === chat, + self?.isCurrentLogRenderTarget(target) == true else { - observation.cancel() + await observation.close() return } - self.selectedChatObservation = observation - self.publishSelectedCodexChatLogChange( - self.logProjection.applyBaseline( - from: observation.chat, - chatCreatedAt: chat.createdAt, - chatUpdatedAt: chat.updatedAt - ), - target: target, - initialRestorationTarget: initialRestorationTarget - ) - for await update in observation.updates { + for await event in observation.updates { guard Task.isCancelled == false, - self.boundChat === chat, - self.isCurrentLogRenderTarget(target) + let owner = self, + owner.boundChat === chat, + owner.isCurrentLogRenderTarget(target) else { - return + break } - self.publishSelectedCodexChatLogChange( - self.logProjection.apply( - update, - in: observation.chat, - chatCreatedAt: chat.createdAt, - chatUpdatedAt: chat.updatedAt - ), + owner.publishSelectedCodexChatLogChange( + owner.logProjection.apply(event), target: target, initialRestorationTarget: initialRestorationTarget ) } + await observation.close() } catch is CancellationError { } catch { logger.error( @@ -330,11 +306,6 @@ package final class ReviewMonitorCodexChatLogTarget { logRenderer = ReviewMonitorLogRenderer() } - private func cancelSelectedChatObservation() { - selectedChatObservation?.cancel() - selectedChatObservation = nil - } - private func cancelPendingLogSourceChange() { pendingLogSourceChangeTask?.cancel() pendingLogSourceChangeTask = nil @@ -848,7 +819,6 @@ package final class ReviewMonitorCodexChatLogTarget { } selectedChatLogTask?.cancel() selectedChatLogTask = nil - cancelSelectedChatObservation() cancelPendingLogSourceChange() resetLogRenderer() boundChatID = chatID diff --git a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift new file mode 100644 index 00000000..c8bfb935 --- /dev/null +++ b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift @@ -0,0 +1,73 @@ +import CodexAppServerKit +import CodexDataKit +import Foundation + +struct ReviewItemPresentationFacts: Equatable, Sendable { + var sourceID: String + var turnID: CodexTurnID? + var kind: CodexThreadItem.Kind + var origin: CodexThreadItem.Origin + var semanticRelation: CodexThreadItem.SemanticRelation? + var displayText: String? +} + +struct ReviewTurnPresentationPolicy: Sendable { + private let hiddenUserMessageTurnIDs: Set + + init( + items: [ReviewItemPresentationFacts], + additionalHiddenTurnIDs: Set = [] + ) { + hiddenUserMessageTurnIDs = additionalHiddenTurnIDs.union(items.compactMap { item in + guard item.kind == .enteredReviewMode || item.kind == .exitedReviewMode else { + return nil + } + return item.turnID + }) + } + + func hidesUserMessage(in turnID: CodexTurnID?) -> Bool { + guard let turnID else { + return false + } + return hiddenUserMessageTurnIDs.contains(turnID) + } +} + +struct ReviewRolloutPresentationPolicy: Sendable { + struct Result: Equatable, Sendable { + var suppressedCompanionSourceIDs: Set + var hiddenUserMessageTurnIDs: Set + var missingTargetSourceIDs: Set + } + + func evaluate(_ items: [ReviewItemPresentationFacts]) -> Result { + var suppressed: Set = [] + var hiddenUserMessageTurnIDs: Set = [] + var missingTargets: Set = [] + for (index, item) in items.enumerated() where isReviewRolloutCompanion(item) { + let target = items[.. Bool { + item.kind == .agentMessage + && item.origin == .reviewRolloutAssistant + && item.semanticRelation == .companionOf(.exitedReviewMode) + } +} diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index d789e100..20b2685e 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -1,5 +1,6 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import ObservationBridge import ReviewChatLogUI diff --git a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift index 9aa1463b..5fc46a6d 100644 --- a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift +++ b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift @@ -1,43 +1,26 @@ -import AppKit import CodexReviewKit @MainActor enum ReviewMonitorAddAccountAction { static func perform(store: CodexReviewStore) { Task { - let auth = store.auth - let previousFailureCount = auth.authenticationFailureCount - let previousWarningMessage = auth.warningMessage - await store.addAccount() - if auth.authenticationFailureCount != previousFailureCount, - let message = auth.errorMessage - { - await presentFailureAlert( - title: "Failed to Add Account", - message: message - ) - } else if let warningMessage = auth.warningMessage, - warningMessage != previousWarningMessage - { - await presentFailureAlert( - title: "Account Updated With Warning", - message: warningMessage - ) + await perform(store: store) { + try await store.addAccount() } } } - private static func presentFailureAlert( - title: String, - message: String + static func perform( + store: CodexReviewStore, + operation: () async throws -> Void ) async { - await MainActor.run { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.runModal() + do { + try await operation() + } catch { + store.auth.presentAccountActionAlert( + title: "Failed to Add Account", + message: error.localizedDescription + ) } } } diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift index 58fc3a67..bacc3afe 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -1,15 +1,19 @@ -import CodexKit +import CodexDataKit import Observation @MainActor @Observable public final class ReviewMonitorCodexModelSource { - public private(set) var modelContext: CodexModelContext? + package private(set) var modelContext: CodexModelContext? @ObservationIgnored private var container: CodexModelContainer? - public init(modelContext: CodexModelContext? = nil) { + public init() { + modelContext = nil + } + + package init(modelContext: CodexModelContext) { self.modelContext = modelContext } diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift index 3a975601..7c65d494 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit @MainActor struct ReviewMonitorCodexSelectionTitlePresentation: Equatable, Sendable { @@ -19,7 +20,7 @@ final class ReviewMonitorCodexSelectionTitleResolver { ) -> ReviewMonitorCodexSelectionTitlePresentation? { switch selection { case .workspaceGroup(let id): - guard let workspaceGroup = modelContext.model(for: id) else { + guard let workspaceGroup = modelContext.registeredModel(for: id) else { return nil } return Self.titlePresentation(for: workspaceGroup) diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index a7602512..285eed08 100644 --- a/Sources/ReviewUI/ReviewMonitorContentView.swift +++ b/Sources/ReviewUI/ReviewMonitorContentView.swift @@ -1,6 +1,6 @@ import AppKit import Combine -import CodexKit +import CodexDataKit import ObservationBridge import CodexReviewKit diff --git a/Sources/ReviewUI/ReviewMonitorSelection.swift b/Sources/ReviewUI/ReviewMonitorSelection.swift index 27093faf..03d80e8d 100644 --- a/Sources/ReviewUI/ReviewMonitorSelection.swift +++ b/Sources/ReviewUI/ReviewMonitorSelection.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexReviewKit enum ReviewMonitorSelectionID: Hashable, Sendable { diff --git a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift index 072fdeab..2b1e656e 100644 --- a/Sources/ReviewUI/ReviewMonitorSplitViewController.swift +++ b/Sources/ReviewUI/ReviewMonitorSplitViewController.swift @@ -1,6 +1,6 @@ import AppKit import Combine -import CodexKit +import CodexDataKit import Foundation import ObservationBridge import CodexReviewKit diff --git a/Sources/ReviewUI/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index 2aba199d..7c8714a4 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -1,10 +1,10 @@ import Observation -import CodexKit +import CodexAppServerKit import CodexReviewKit @MainActor @Observable -public final class ReviewMonitorUIState { +package final class ReviewMonitorUIState { let auth: CodexReviewAuthModel private let persistSidebarReviewChatFilter: (SidebarReviewChatFilter) -> Void var selection: ReviewMonitorSelection? diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 870526bb..1fc069c4 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -1,5 +1,5 @@ import AppKit -import CodexKit +import CodexDataKit import CodexReviewKit @MainActor @@ -19,7 +19,7 @@ public final class ReviewMonitorWindowController: NSWindowController { private static let frameAutosaveName = NSWindow.FrameAutosaveName("ReviewMonitor.MainWindow") private let rootViewController: ReviewMonitorRootViewController - public convenience init(store: CodexReviewStore) { + package convenience init(store: CodexReviewStore) { self.init( store: store, codexModelSource: nil, @@ -28,7 +28,7 @@ public final class ReviewMonitorWindowController: NSWindowController { ) } - public convenience init( + package convenience init( store: CodexReviewStore, codexModelContext: CodexModelContext ) { @@ -52,7 +52,7 @@ public final class ReviewMonitorWindowController: NSWindowController { ) } - package convenience init( + public convenience init( store: CodexReviewStore, codexModelSource: ReviewMonitorCodexModelSource, showSettings: @escaping @MainActor () -> Void diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 5292e4ee..4024d1c3 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -1,8 +1,15 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexReviewKit +import OSLog import SwiftUI +private let chatContextMenuLogger = Logger( + subsystem: "CodexReviewKit", + category: "chat-context-menu" +) + @MainActor struct ReviewMonitorChatArchiveConfirmation: Sendable { typealias Action = @MainActor @Sendable (_ chatID: CodexThreadID, _ title: String) async -> Bool @@ -61,13 +68,19 @@ struct ReviewMonitorChatContextMenuView: View { let chatID = chat.id.rawValue let action = cancellationCapability.action Task { - switch action { - case .some(.reviewRun): - _ = try? await store.cancelReview(chatID: chatID, cancellation: .userInterface()) - case .some(.directChat): - _ = try? await chat.cancel() - case nil: - break + do { + switch action { + case .some(.reviewRun): + _ = try await store.cancelReview(chatID: chatID, cancellation: .userInterface()) + case .some(.directChat): + try await chat.cancel() + case nil: + break + } + } catch { + chatContextMenuLogger.error( + "Failed to cancel chat \(chatID, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) } } } @@ -84,7 +97,13 @@ struct ReviewMonitorChatContextMenuView: View { return } } - try? await chat.archive() + do { + try await chat.archive() + } catch { + chatContextMenuLogger.error( + "Failed to archive chat \(chat.id.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } } } diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index f7060d03..b294327f 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -import CodexKit +import CodexDataKit @MainActor struct ReviewMonitorChatRowView: View { diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift index b2d52f30..e5ae5aef 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorCodexSidebarOutline.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexReviewKit import Foundation diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 0e848106..ceee4e7a 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -1,5 +1,6 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation import ObservationBridge import CodexReviewKit @@ -97,7 +98,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD private var codexSidebarObservation: PortableObservationTracking.Token? private var codexSidebarTransactionTask: Task? private var codexSidebarFetchTask: Task? - private var codexSidebarResultsController: CodexFetchedResultsController? + private var codexSidebarResults: CodexFetchedResults? private var codexSidebarModelContext: CodexModelContext? private var codexSidebarPresentationOrder = ReviewMonitorCodexSidebarPresentationOrder() private let codexSidebarOutlineTree = ReviewMonitorCodexSidebarOutlineTree() @@ -278,14 +279,14 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD static var defaultCodexSidebarDescriptor: CodexFetchDescriptor { CodexFetchDescriptor( predicate: #Predicate { $0.isArchived == false }, - sortBy: [SortDescriptor(\.recencyAt, order: .reverse)] + sortBy: [CodexSortDescriptor(\.recencyAt, order: .reverse)] ) } - private static func makeCodexSidebarFetchedResultsController( + private static func makeCodexSidebarFetchedResults( modelContext: CodexModelContext - ) -> CodexFetchedResultsController { - modelContext.fetchedResultsController( + ) -> CodexFetchedResults { + modelContext.fetchedResults( for: defaultCodexSidebarDescriptor, sectionedBy: .workspaceGroup ) @@ -305,7 +306,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let modelContext, let codexSidebarModelContext, codexSidebarModelContext === modelContext, - codexSidebarResultsController != nil + codexSidebarResults != nil { return } @@ -315,45 +316,45 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD codexSidebarTransactionTask = nil guard let modelContext else { codexSidebarModelContext = nil - codexSidebarResultsController = nil + codexSidebarResults = nil applyCodexSidebarSourceSections([]) return } - let resultsController = Self.makeCodexSidebarFetchedResultsController(modelContext: modelContext) + let results = Self.makeCodexSidebarFetchedResults(modelContext: modelContext) codexSidebarModelContext = modelContext - codexSidebarResultsController = resultsController - codexSidebarTransactionTask = Task { @MainActor [weak self, resultsController] in - for await transaction in resultsController.transactions { + codexSidebarResults = results + codexSidebarTransactionTask = Task { @MainActor [weak self, results] in + for await transaction in results.transactions { guard let self, - self.codexSidebarResultsController === resultsController + self.codexSidebarResults === results else { return } - self.applyCodexSidebarTransaction(transaction, controller: resultsController) + self.applyCodexSidebarTransaction(transaction, results: results) } } - codexSidebarFetchTask = Task { @MainActor [weak self, resultsController] in + codexSidebarFetchTask = Task { @MainActor [weak self, results] in do { - try await resultsController.performFetch() + try await results.performFetch() } catch is CancellationError { } catch { } - guard self?.codexSidebarResultsController === resultsController + guard self?.codexSidebarResults === results else { return } - self?.applyCodexSidebarSourceSections(resultsController.sections) + self?.applyCodexSidebarSourceSections(results.sections) self?.codexSidebarFetchTask = nil } } private func applyCodexSidebarTransaction( _ transaction: CodexFetchedResultsTransaction, - controller: CodexFetchedResultsController + results: CodexFetchedResults ) { clearRemovedChatSelectionIfNeeded(transaction) - applyCodexSidebarSourceSections(controller.sections) + applyCodexSidebarSourceSections(results.sections) } private func clearRemovedChatSelectionIfNeeded( @@ -374,7 +375,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private var codexSidebarSourceSections: [CodexFetchSection] { - codexSidebarResultsController?.sections ?? [] + codexSidebarResults?.sections ?? [] } private func codexSidebarVisibleSections( @@ -871,7 +872,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD else { return nil } - return displayedCodexChat(id: id) + return codexSidebarModelContext?.registeredModel(for: id) + ?? currentChatSelection(id: id) } private func dragPayload(for item: Any) -> SidebarDragPayload? { @@ -1549,7 +1551,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var codexSidebarSectionsForTesting: [CodexFetchSection] { - codexSidebarResultsController?.sections ?? [] + codexSidebarResults?.sections ?? [] } var codexSidebarRootTitlesForTesting: [String] { @@ -1635,8 +1637,8 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } func refreshCodexSidebarForTesting() async throws { - try await codexSidebarResultsController?.refresh() - applyCodexSidebarSourceSections(codexSidebarResultsController?.sections ?? []) + try await codexSidebarResults?.refresh() + applyCodexSidebarSourceSections(codexSidebarResults?.sections ?? []) } var sidebarFullReloadCountForTesting: Int { diff --git a/Sources/ReviewUI/SignIn/SignInView.swift b/Sources/ReviewUI/SignIn/SignInView.swift index caa2d2b8..ae1f8787 100644 --- a/Sources/ReviewUI/SignIn/SignInView.swift +++ b/Sources/ReviewUI/SignIn/SignInView.swift @@ -3,6 +3,7 @@ import CodexReviewKit struct SignInView: View { let store: CodexReviewStore + @State private var authenticationFailureMessage: String? var body: some View { ContentUnavailableView { @@ -15,7 +16,11 @@ struct SignInView: View { Button(role: store.auth.isAuthenticating ? .cancel : .confirm) { Task { @MainActor in - await store.performPrimaryAuthenticationAction() + do { + try await store.performPrimaryAuthenticationAction() + } catch { + authenticationFailureMessage = error.localizedDescription + } } } label: { LabeledContent { @@ -43,6 +48,19 @@ struct SignInView: View { } .animation(.default, value: store.auth.isAuthenticating) .scenePadding() + .alert( + "Authentication Request Failed", + isPresented: Binding( + get: { authenticationFailureMessage != nil }, + set: { if $0 == false { authenticationFailureMessage = nil } } + ) + ) { + Button("OK") { + authenticationFailureMessage = nil + } + } message: { + Text(authenticationFailureMessage ?? "Authentication request failed.") + } } private var descriptionText: String? { diff --git a/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift new file mode 100644 index 00000000..eb9c7fad --- /dev/null +++ b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift @@ -0,0 +1,483 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import CodexReviewKit +import Foundation +import ReviewUI + +private struct PreviewRuntimeNotificationWork: Sendable { + let sequence: UInt64 + let notification: ReviewMonitorPreviewRuntimeNotification +} + +private actor PreviewRuntimeNotificationDrain { + private var completedSequence: UInt64 = 0 + private var isFinished = false + private var waiters: [(UInt64, CheckedContinuation)] = [] + + func complete(_ sequence: UInt64) { + precondition(sequence == completedSequence &+ 1) + completedSequence = sequence + resumeSatisfiedWaiters() + } + + func wait(until sequence: UInt64) async { + guard isFinished == false, completedSequence < sequence else { + return + } + await withCheckedContinuation { continuation in + if isFinished || completedSequence >= sequence { + continuation.resume() + } else { + waiters.append((sequence, continuation)) + } + } + } + + func finish() { + guard isFinished == false else { + return + } + isFinished = true + let waiters = waiters + self.waiters.removeAll(keepingCapacity: false) + for (_, waiter) in waiters { + waiter.resume() + } + } + + private func resumeSatisfiedWaiters() { + var remaining: [(UInt64, CheckedContinuation)] = [] + for waiter in waiters { + if waiter.0 <= completedSequence { + waiter.1.resume() + } else { + remaining.append(waiter) + } + } + waiters = remaining + } +} + +@MainActor +final class PreviewRuntimeLifecycleState { + private(set) var fullCloseCount = 0 + private(set) var emittedNotificationCount = 0 + + fileprivate func recordFullClose() { + fullCloseCount += 1 + } + + fileprivate func recordNotificationEmission() { + emittedNotificationCount += 1 + } +} + +@MainActor +private final class PreviewRuntimeLifetimeCommitSink { + private weak var lifetime: PreviewRuntimeLifetime? + + init(lifetime: PreviewRuntimeLifetime) { + self.lifetime = lifetime + } + + func install( + runtime: CodexAppServerTestRuntime, + container: CodexModelContainer + ) -> Bool { + lifetime?.install(runtime: runtime, container: container) == true + } + + func finishStartWithoutInstalling() { + lifetime?.finishStartWithoutInstalling() + } + + func appendStreamTick() async { + guard let lifetime else { + return + } + _ = await lifetime.appendPreviewStreamTick( + after: lifetime.currentTick, + emitsNotifications: true + ) + } + + func cancelPreviewChat(_ chatID: CodexThreadID) async { + await lifetime?.cancelPreviewChat(chatID) + } +} + +@MainActor +final class PreviewRuntimeLifetime: CodexReviewPreviewRuntimeLifetime { + private enum Phase { + case active + case stopping(Task) + case stopped + } + + let modelSource = ReviewMonitorCodexModelSource() + + private let eventSink: ReviewMonitorPreviewRuntimeEventSink + private let notificationStream: AsyncStream + private let notificationContinuation: AsyncStream.Continuation + private let notificationDrain = PreviewRuntimeNotificationDrain() + let lifecycleStateForTesting = PreviewRuntimeLifecycleState() + private var commitSink: PreviewRuntimeLifetimeCommitSink! + private var runtime: CodexAppServerTestRuntime? + private var container: CodexModelContainer? + private var startTask: Task? + private var streamTask: Task? + private var notificationTask: Task? + private var notificationSequence: UInt64 = 0 + private var phase = Phase.active + private var acceptsEvents = true + private var cancellationSignalled = false + private(set) var stopOperationCountForTesting = 0 + + init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { + eventSink = ReviewMonitorPreviewRuntimeEventSink(fixtures: fixtures) + let pair = AsyncStream.makeStream(of: PreviewRuntimeNotificationWork.self) + notificationStream = pair.stream + notificationContinuation = pair.continuation + commitSink = PreviewRuntimeLifetimeCommitSink(lifetime: self) + } + + isolated deinit { + signalCancellation() + } + + var initialChatID: CodexThreadID? { + eventSink.initialChatID + } + + var currentTick: Int { + eventSink.currentTick + } + + func start() { + guard acceptsEvents, runtime == nil, startTask == nil else { + return + } + let threadStore = eventSink.threadStore + let commitSink = commitSink! + startTask = Task { @MainActor in + var startedRuntime: CodexAppServerTestRuntime? + do { + let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore) + startedRuntime = runtime + try Task.checkCancellation() + let container = CodexModelContainer(appServer: runtime.server) + try await runtime.transport.handleTurnInterrupt { [commitSink] request in + await commitSink.cancelPreviewChat(request.threadID) + } + try Task.checkCancellation() + guard commitSink.install(runtime: runtime, container: container) else { + await runtime.close() + commitSink.finishStartWithoutInstalling() + return + } + } catch is CancellationError { + await startedRuntime?.close() + commitSink.finishStartWithoutInstalling() + } catch { + await startedRuntime?.close() + preconditionFailure("Failed to start the Preview app-server runtime: \(error)") + } + } + } + + func startStreaming(interval: Duration) { + guard acceptsEvents, streamTask == nil else { + return + } + start() + let commitSink = commitSink! + streamTask = Task { @MainActor in + do { + while Task.isCancelled == false { + try await Task.sleep(for: interval) + try Task.checkCancellation() + await commitSink.appendStreamTick() + } + } catch is CancellationError { + return + } catch { + preconditionFailure("Preview stream sleep failed: \(error)") + } + } + } + + func signalCancellation() { + guard cancellationSignalled == false else { + return + } + cancellationSignalled = true + acceptsEvents = false + startTask?.cancel() + streamTask?.cancel() + notificationTask?.cancel() + notificationContinuation.finish() + } + + func stop() async { + switch phase { + case .active: + signalCancellation() + stopOperationCountForTesting += 1 + + let startTask = startTask + let streamTask = streamTask + let notificationTask = notificationTask + let runtime = runtime + let container = container + let modelSource = modelSource + let notificationDrain = notificationDrain + let lifecycleState = lifecycleStateForTesting + + self.startTask = nil + self.streamTask = nil + self.notificationTask = nil + self.runtime = nil + self.container = nil + + let completion = Task { @MainActor in + await streamTask?.value + await startTask?.value + await notificationTask?.value + await notificationDrain.finish() + modelSource.clear() + if let runtime { + await runtime.close() + lifecycleState.recordFullClose() + } + _ = container + } + phase = .stopping(completion) + await completion.value + phase = .stopped + + case .stopping(let completion): + await completion.value + phase = .stopped + + case .stopped: + return + } + } + + func waitUntilStopped() async { + guard case .stopping(let completion) = phase else { + return + } + await completion.value + phase = .stopped + } + + func cancelStreaming() { + streamTask?.cancel() + } + + func stopStreaming() async { + let task = streamTask + streamTask = nil + task?.cancel() + await task?.value + await notificationDrain.wait(until: notificationSequence) + } + + func upsertPreviewItem( + _ item: CodexAppServerTestItem, + to chatID: CodexThreadID + ) async { + guard acceptsEvents, + let notification = await eventSink.prepareUpsertPreviewItem(item, to: chatID), + acceptsEvents else { + return + } + guard await ensureStarted() else { + return + } + enqueue(notification) + } + + func appendPreviewText( + _ delta: String, + to chatID: CodexThreadID, + itemID: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content + ) async { + guard acceptsEvents, + let notification = await eventSink.prepareAppendPreviewText( + delta, + to: chatID, + itemID: itemID, + kind: kind, + content: content + ), + acceptsEvents else { + return + } + guard await ensureStarted() else { + return + } + enqueue(notification) + } + + @discardableResult + func appendPreviewStreamTick( + after tick: Int, + emitsNotifications: Bool + ) async -> Int { + guard acceptsEvents else { + return tick + } + if emitsNotifications, await ensureStarted() == false { + return tick + } + guard acceptsEvents else { + return tick + } + let mutation = await eventSink.prepareStreamMutation(after: tick) + guard acceptsEvents else { + return mutation.tick + } + if emitsNotifications { + for notification in mutation.notifications { + enqueue(notification) + } + } + return mutation.tick + } + + func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { + await eventSink.snapshotForTesting(chatID: chatID) + } + + func observedTurnStateForTesting( + chatID: CodexThreadID + ) -> CodexTurnSnapshot.State? { + container?.mainContext.registeredModel(for: chatID)?.turns.last?.state + } + + func interruptRequestCountForTesting() async -> Int { + guard let runtime else { + return 0 + } + return await runtime.transport.recordedRequests(for: .turnInterrupt).count + } + + func turnCompletionNotificationCountForTesting() -> Int { + eventSink.turnCompletionNotificationCountForTesting() + } + + func archiveRequestCountForTesting() async -> Int { + guard let runtime else { + return 0 + } + return await runtime.transport.recordedRequests(for: .threadArchive).count + } + + func startAndWaitForTesting() async { + start() + let task = startTask + await task?.value + precondition(runtime != nil || acceptsEvents == false) + } + + var runtimeTransportForTesting: CodexAppServerTestTransport? { + runtime?.transport + } + + var runtimeServerForTesting: CodexAppServer? { + runtime?.server + } + + var modelContainerForTesting: CodexModelContainer? { + container + } + + var activeTaskCountForTesting: Int { + [startTask, streamTask, notificationTask].compactMap { $0 }.count + } + + private func ensureStarted() async -> Bool { + guard acceptsEvents else { + return false + } + if runtime != nil { + return true + } + start() + let task = startTask + await task?.value + return acceptsEvents && runtime != nil + } + + fileprivate func install( + runtime: CodexAppServerTestRuntime, + container: CodexModelContainer + ) -> Bool { + guard acceptsEvents, self.runtime == nil else { + return false + } + self.runtime = runtime + self.container = container + startNotificationWorker(using: runtime) + modelSource.install(container: container) + startTask = nil + return true + } + + fileprivate func finishStartWithoutInstalling() { + startTask = nil + } + + private func startNotificationWorker(using runtime: CodexAppServerTestRuntime) { + precondition(notificationTask == nil) + let stream = notificationStream + let eventSink = eventSink + let notificationDrain = notificationDrain + let lifecycleState = lifecycleStateForTesting + notificationTask = Task { @MainActor in + for await work in stream { + guard Task.isCancelled == false else { + break + } + do { + try await eventSink.emit(work.notification, using: runtime) + } catch is CancellationError { + precondition( + Task.isCancelled, + "A Preview notification emission can be cancelled only by runtime shutdown." + ) + break + } catch { + preconditionFailure("Failed to emit a Preview runtime notification: \(error)") + } + lifecycleState.recordNotificationEmission() + await notificationDrain.complete(work.sequence) + } + await notificationDrain.finish() + } + } + + private func enqueue(_ notification: ReviewMonitorPreviewRuntimeNotification) { + precondition(acceptsEvents) + precondition(runtime != nil) + notificationSequence &+= 1 + notificationContinuation.yield(.init( + sequence: notificationSequence, + notification: notification + )) + } + + fileprivate func cancelPreviewChat(_ chatID: CodexThreadID) async { + guard acceptsEvents, + let notification = await eventSink.prepareCancellation(chatID: chatID), + acceptsEvents else { + return + } + enqueue(notification) + } +} diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 51572de7..9e1a68f5 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexAppServerKitTesting import CodexReviewKit import Foundation @@ -22,18 +23,6 @@ private actor ReviewMonitorPreviewSnapshotMutationQueue { } } -private actor ReviewMonitorPreviewArchivedChatIDs { - private var ids: Set = [] - - func insert(_ id: CodexThreadID) { - ids.insert(id) - } - - func contains(_ id: CodexThreadID) -> Bool { - ids.contains(id) - } -} - private actor ReviewMonitorPreviewCancelledChatIDs { private var ids: Set = [] @@ -46,35 +35,43 @@ private actor ReviewMonitorPreviewCancelledChatIDs { } } -private struct ReviewMonitorPreviewStoredThreadItem: Sendable { +struct ReviewMonitorPreviewStoredThreadItem: Sendable { var item: CodexThreadItem var turnID: CodexTurnID + var fixtureItem: CodexAppServerTestItem? } -@MainActor -struct ReviewMonitorPreviewChatLogFixture { +struct ReviewMonitorPreviewChatLogFixture: Sendable { let chatID: CodexThreadID let title: String - let preview: String? - let model: String? - let workspaceCWD: String? - let updatedAt: Date? + let preview: String + let model: String + let modelProvider: String + let workspaceCWD: String + let createdAt: Date + let updatedAt: Date let recencyAt: Date? - let status: CodexThreadStatus? + let status: CodexThreadStatus let cwd: String let streamID: String let isRunning: Bool - let initialThreadSnapshot: CodexThreadSnapshot + let storedThread: CodexAppServerTestStoredThread + + var initialThreadSnapshot: CodexThreadSnapshot { + storedThread.snapshot + } init( chatID: CodexThreadID, title: String, - preview: String?, - model: String?, - workspaceCWD: String?, - updatedAt: Date?, + preview: String, + model: String, + modelProvider: String, + workspaceCWD: String, + createdAt: Date, + updatedAt: Date, recencyAt: Date?, - status: CodexThreadStatus?, + status: CodexThreadStatus, cwd: String, streamID: String, isRunning: Bool, @@ -84,40 +81,85 @@ struct ReviewMonitorPreviewChatLogFixture { self.title = title self.preview = preview self.model = model + self.modelProvider = modelProvider self.workspaceCWD = workspaceCWD + self.createdAt = createdAt self.updatedAt = updatedAt self.recencyAt = recencyAt self.status = status self.cwd = cwd self.streamID = streamID self.isRunning = isRunning - self.initialThreadSnapshot = initialThreadSnapshot + do { + self.storedThread = try makePreviewStoredThread( + chatID: chatID, + title: title, + preview: preview, + model: model, + modelProvider: modelProvider, + workspaceCWD: workspaceCWD, + createdAt: createdAt, + updatedAt: updatedAt, + recencyAt: recencyAt, + status: status, + cwd: cwd, + initialThreadSnapshot: initialThreadSnapshot + ) + } catch { + preconditionFailure("Invalid Preview stored-thread fixture: \(error)") + } } } -@MainActor -final class ReviewMonitorPreviewAppServerRuntime { - let modelSource = ReviewMonitorCodexModelSource() +enum ReviewMonitorPreviewRuntimeNotification: Sendable { + case itemLifecycle( + ReviewMonitorPreviewStoredThreadItem, + ReviewMonitorPreviewChatLogFixture + ) + case textDelta( + delta: String, + itemID: String, + turnID: CodexTurnID, + chatID: CodexThreadID, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content + ) + case stream( + ReviewMonitorPreviewContent.PreviewChatLogStreamStep, + ReviewMonitorPreviewStoredThreadItem, + ReviewMonitorPreviewChatLogFixture + ) + case cancelled( + CodexAppServerTestStoredThread, + ReviewMonitorPreviewChatLogFixture + ) +} +struct ReviewMonitorPreviewStreamMutation: Sendable { + let tick: Int + let notifications: [ReviewMonitorPreviewRuntimeNotification] +} + +@MainActor +final class ReviewMonitorPreviewRuntimeEventSink { private let fixtures: [ReviewMonitorPreviewChatLogFixture] private let fixturesByChatID: [CodexThreadID: ReviewMonitorPreviewChatLogFixture] - private var threadStore: CodexAppServerTestThreadStore - private var runtime: CodexAppServerTestRuntime? - private var container: CodexModelContainer? - private var startTask: Task? - private var streamTask: Task? - private var notificationTask: Task? + let threadStore: CodexAppServerTestThreadStore + private var turnCompletionNotificationCount = 0 private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue() - private let archivedChatIDs = ReviewMonitorPreviewArchivedChatIDs() private let cancelledChatIDs = ReviewMonitorPreviewCancelledChatIDs() - private var tick = 0 + private(set) var currentTick = 0 init(fixtures: [ReviewMonitorPreviewChatLogFixture]) { self.fixtures = fixtures self.fixturesByChatID = Dictionary(uniqueKeysWithValues: fixtures.map { ($0.chatID, $0) }) - self.threadStore = CodexAppServerTestThreadStore( - threads: fixtures.map(\.threadSnapshot) - ) + do { + self.threadStore = try CodexAppServerTestThreadStore( + threads: fixtures.map(\.storedThread) + ) + } catch { + preconditionFailure("Invalid Preview thread store: \(error)") + } } var initialChatID: CodexThreadID? { @@ -132,261 +174,187 @@ final class ReviewMonitorPreviewAppServerRuntime { } func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { - await threadStore.snapshot(id: chatID) + await threadStore.storedThread(id: chatID)?.snapshot } - func interruptRequestCountForTesting() async -> Int { - guard let runtime else { - return 0 - } - return await runtime.transport.recordedRequests(method: "turn/interrupt").count + func turnCompletionNotificationCountForTesting() -> Int { + turnCompletionNotificationCount } - func archiveRequestCountForTesting() async -> Int { - guard let runtime else { - return 0 - } - return await runtime.transport.recordedRequests(method: "thread/archive").count - } - - func upsertPreviewItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, + func prepareUpsertPreviewItem( + _ item: CodexAppServerTestItem, to chatID: CodexThreadID - ) async { + ) async -> ReviewMonitorPreviewRuntimeNotification? { guard let fixture = fixturesByChatID[chatID] else { - return - } - guard let item = await upsertStoredItem( - id: id, - kind: kind, - content: content, - in: fixture - ) else { - return + return nil } - start() - enqueueNotification { [weak self] in - await self?.emitItem(item, for: fixture) + guard let storedItem = await upsertStoredItem(item, in: fixture) else { + return nil } + return .itemLifecycle(storedItem, fixture) } - func appendPreviewText( + func prepareAppendPreviewText( _ delta: String, to chatID: CodexThreadID, itemID: String, kind: CodexThreadItem.Kind, content: CodexThreadItem.Content - ) async { + ) async -> ReviewMonitorPreviewRuntimeNotification? { guard delta.isEmpty == false, fixturesByChatID[chatID] != nil else { - return + return nil } guard let item = await appendStoredText( delta, itemID: itemID, - kind: kind, - content: content, in: chatID ) else { - return - } - start() - enqueueNotification { [weak self] in - do { - try await self?.ensureStarted() - guard let runtime = self?.runtime else { - return - } - try await self?.emitTextDelta( - delta, - itemID: itemID, - turnID: item.turnID, - chatID: chatID, - kind: kind, - content: item.item.content, - runtime: runtime - ) - } catch { - } - } - } - - private func enqueueNotification(_ operation: @escaping @MainActor () async -> Void) { - let previousTask = notificationTask - notificationTask = Task { @MainActor in - await previousTask?.value - await operation() - } - } - - func start() { - guard startTask == nil, runtime == nil else { - return - } - startTask = Task { @MainActor [weak self] in - do { - try await self?.startNow() - } catch { - } - } - } - - func startStreaming(interval: Duration) { - start() - guard streamTask == nil else { - return - } - streamTask = Task { @MainActor [weak self] in - while Task.isCancelled == false { - try? await Task.sleep(for: interval) - guard let self, Task.isCancelled == false else { - return - } - _ = await self.appendPreviewStreamTick( - after: self.tick, - emitsNotifications: true - ) - } + return nil } + return .textDelta( + delta: delta, + itemID: itemID, + turnID: item.turnID, + chatID: chatID, + kind: kind, + content: item.item.content + ) } - @discardableResult - func appendPreviewStreamTick( - after currentTick: Int = 0, - emitsNotifications: Bool = false - ) async -> Int { - var runningFixtures: [ReviewMonitorPreviewChatLogFixture] = [] - for fixture in fixtures where fixture.isRunning { - if await cancelledChatIDs.contains(fixture.chatID) == false { - runningFixtures.append(fixture) + func prepareStreamMutation( + after currentTick: Int + ) async -> ReviewMonitorPreviewStreamMutation { + var runningFixtures: [(index: Int, fixture: ReviewMonitorPreviewChatLogFixture)] = [] + for (index, fixture) in fixtures.filter(\.isRunning).enumerated() { + if await cancelledChatIDs.contains(fixture.chatID) == false, + await threadStore.storedThread(id: fixture.chatID)?.isArchived == false + { + runningFixtures.append((index, fixture)) } } guard runningFixtures.isEmpty == false else { - return currentTick - } - - if emitsNotifications { - do { - try await ensureStarted() - } catch { - return currentTick - } + return .init(tick: currentTick, notifications: []) } let nextTick = currentTick + 1 - for (index, fixture) in runningFixtures.enumerated() { - guard let frame = ReviewMonitorPreviewContent.streamFrame( - forRunningChatAt: index, - tick: nextTick - ) else { + var notifications: [ReviewMonitorPreviewRuntimeNotification] = [] + for (index, fixture) in runningFixtures { + guard + let frame = ReviewMonitorPreviewContent.streamFrame( + forRunningChatAt: index, + tick: nextTick + ) + else { continue } - guard let storedItem = await apply(frame.step, cycle: frame.cycle, for: fixture) else { - continue - } - if emitsNotifications { - enqueueNotification { [weak self] in - await self?.emit(frame.step, storedItem: storedItem, for: fixture) + guard let storedItem = await apply(frame.step, cycle: frame.cycle, for: fixture) else { + continue } + notifications.append(.stream(frame.step, storedItem, fixture)) } - } - tick = nextTick - return nextTick - } - - private func ensureStarted() async throws { - if runtime != nil { - return - } - if let startTask { - await startTask.value - return - } - try await startNow() + self.currentTick = nextTick + return .init(tick: nextTick, notifications: notifications) } - private func startNow() async throws { - if runtime != nil { - return - } - let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore) - let container = CodexModelContainer(appServer: runtime.server) - self.runtime = runtime - try await rebindRuntimeToCurrentThreadStore(runtime) - await runtime.transport.handle(method: "turn/interrupt") { params in - let request = try JSONDecoder().decode(PreviewTurnInterruptParams.self, from: params) - let threadID = CodexThreadID(rawValue: request.threadID) - await self.cancelPreviewChat(threadID) - return Data("{}".utf8) - } - self.container = container - modelSource.install(container: container) - } - - private func rebindRuntimeToCurrentThreadStore(_ runtime: CodexAppServerTestRuntime) async throws { - var reboundStore: CodexAppServerTestThreadStore - repeat { - reboundStore = threadStore - try await runtime.transport.stubThreads(reboundStore) - let archivedChatIDs = archivedChatIDs - let store = reboundStore - await runtime.transport.handle(method: "thread/archive") { params in - let request = try JSONDecoder().decode(PreviewThreadArchiveParams.self, from: params) - let threadID = CodexThreadID(rawValue: request.threadID) - await archivedChatIDs.insert(threadID) - await store.remove(id: threadID) - return Data("{}".utf8) - } - } while reboundStore !== threadStore - } - - private func cancelPreviewChat(_ chatID: CodexThreadID) async { + func prepareCancellation( + chatID: CodexThreadID + ) async -> ReviewMonitorPreviewRuntimeNotification? { guard let fixture = fixturesByChatID[chatID] else { - return + return nil } await cancelledChatIDs.insert(chatID) - guard let cancelledSnapshot = await updateStoredSnapshot(for: fixture, mutation: { snapshot in - snapshot.turns = snapshot.turns?.map { turn in - var turn = turn - if turn.status.isTerminalForPreview == false { - turn.status = .cancelled + let cancelledThread: CodexAppServerTestStoredThread? = await snapshotMutationQueue.run { @MainActor [weak self] in + guard let self, + let stored = await self.threadStore.storedThread(id: fixture.chatID) else { + return nil + } + do { + let turns = try stored.turns.map { turn in + guard turn.snapshot.state.isTerminalForPreview == false else { + return turn + } + var snapshot = turn.snapshot + snapshot.state = .interrupted + return try CodexAppServerTestTurn( + snapshot: snapshot, + items: turn.items + ) } - return turn + let updated = try stored.replacingTurns(turns).replacingStatus(.idle) + await self.threadStore.upsert(updated) + return updated + } catch { + preconditionFailure("Failed to interrupt a Preview stored thread: \(error)") } - }) else { - return } - enqueueNotification { [weak self] in - await self?.emitCancelledState(cancelledSnapshot, for: fixture) + guard let cancelledThread else { + return nil + } + return .cancelled(cancelledThread, fixture) + } + + func emit( + _ notification: ReviewMonitorPreviewRuntimeNotification, + using runtime: CodexAppServerTestRuntime + ) async throws { + switch notification { + case .itemLifecycle(let storedItem, let fixture): + try await emitItemLifecycle(storedItem, for: fixture, using: runtime) + case .textDelta(let delta, let itemID, let turnID, let chatID, let kind, let content): + try await emitTextDelta( + delta, + itemID: itemID, + turnID: turnID, + chatID: chatID, + kind: kind, + content: content, + runtime: runtime + ) + case .stream(let step, let storedItem, let fixture): + try await emit(step, storedItem: storedItem, for: fixture, using: runtime) + case .cancelled(let storedThread, let fixture): + try await emitCancelledState(storedThread, for: fixture, using: runtime) } } private func emit( _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, storedItem: ReviewMonitorPreviewStoredThreadItem, - for fixture: ReviewMonitorPreviewChatLogFixture - ) async { - guard let runtime else { - return - } - do { + for fixture: ReviewMonitorPreviewChatLogFixture, + using runtime: CodexAppServerTestRuntime + ) async throws { + switch step.mode { + case .textDelta: + try await emitTextDelta( + step.deltaText ?? "", + itemID: storedItem.item.id, + turnID: storedItem.turnID, + chatID: fixture.chatID, + kind: storedItem.item.kind, + content: storedItem.item.content, + runtime: runtime + ) + case .update, .complete: + guard let fixtureItem = storedItem.fixtureItem else { + preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") + } switch step.mode { - case .textDelta: - try await emitTextDelta( - step.deltaText ?? "", - itemID: storedItem.item.id, + case .update: + try await runtime.notificationEmitter.emitItemStarted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) + case .complete: + try await runtime.notificationEmitter.emitItemCompleted( + threadID: fixture.chatID, turnID: storedItem.turnID, - chatID: fixture.chatID, - kind: storedItem.item.kind, - content: storedItem.item.content, - runtime: runtime + item: fixtureItem ) - case .update, .complete: - await emitItem(storedItem, for: fixture) + case .textDelta: + preconditionFailure("Text deltas are handled above.") } - } catch { } } @@ -402,176 +370,130 @@ final class ReviewMonitorPreviewAppServerRuntime { ) switch step.mode { case .update, .complete: - return await upsertStoredItem( - id: itemID, - kind: step.kind, - content: step.content, - in: fixture - ) + do { + return await upsertStoredItem( + try makePreviewTestItem( + id: itemID, + kind: step.kind, + content: step.content, + cwd: fixture.cwd + ), + in: fixture + ) + } catch { + preconditionFailure("Invalid preview item fixture: \(error)") + } case .textDelta: return await appendStoredText( step.deltaText ?? "", itemID: itemID, - kind: step.kind, - content: step.content, in: fixture.chatID ) } } private func upsertStoredItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, + _ fixtureItem: CodexAppServerTestItem, in fixture: ReviewMonitorPreviewChatLogFixture ) async -> ReviewMonitorPreviewStoredThreadItem? { - let fallbackTurnID = fixture.previewFallbackTurnID - return await updateStoredSnapshot(for: fixture) { snapshot in - let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID) - let item = CodexThreadItem( - id: id, - kind: kind, - content: content - ) - guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else { + return await snapshotMutationQueue.run { @MainActor [weak self] in + guard let self, + await self.cancelledChatIDs.contains(fixture.chatID) == false, + let stored = await self.threadStore.storedThread(id: fixture.chatID), + stored.isArchived == false else { return nil } - if let itemIndex = snapshot.turns?[turnIndex].items.firstIndex(where: { $0.id == item.id }) { - snapshot.turns?[turnIndex].items[itemIndex] = item - } else { - snapshot.turns?[turnIndex].items.append(item) + do { + var turns = stored.turns + let turnIndex = try turns.requirePreviewTurn() + var items = turns[turnIndex].items + if let itemIndex = items.firstIndex(where: { + $0.domainProjection.id == fixtureItem.domainProjection.id + }) { + items[itemIndex] = fixtureItem + } else { + items.append(fixtureItem) + } + turns[turnIndex] = try turns[turnIndex].replacingItems(items) + await self.threadStore.upsert(try stored.replacingTurns(turns)) + return ReviewMonitorPreviewStoredThreadItem( + item: fixtureItem.domainProjection, + turnID: turns[turnIndex].snapshot.id, + fixtureItem: fixtureItem + ) + } catch { + preconditionFailure("Failed to update a Preview stored item: \(error)") } - return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) } } private func appendStoredText( _ delta: String, itemID: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, in chatID: CodexThreadID ) async -> ReviewMonitorPreviewStoredThreadItem? { guard delta.isEmpty == false, let fixture = fixturesByChatID[chatID] else { return nil } - let fallbackTurnID = fixture.previewFallbackTurnID - return await updateStoredSnapshot(for: fixture) { snapshot in - let turnID = snapshot.ensurePreviewTurn(fallback: fallbackTurnID) - guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else { + return await snapshotMutationQueue.run { @MainActor [weak self] in + guard let self, + await self.cancelledChatIDs.contains(fixture.chatID) == false, + let stored = await self.threadStore.storedThread(id: fixture.chatID), + stored.isArchived == false else { return nil } - if let itemIndex = snapshot.turns?[turnIndex].items.firstIndex(where: { $0.id == itemID }) { - snapshot.turns?[turnIndex].items[itemIndex].content.appendPreviewText(delta) - guard let item = snapshot.turns?[turnIndex].items[itemIndex] else { - return nil + do { + var turns = stored.turns + let turnIndex = try turns.requirePreviewTurn() + var items = turns[turnIndex].items + guard let itemIndex = items.firstIndex(where: { + $0.domainProjection.id == itemID + }) else { + preconditionFailure("A preview text delta requires a previously started item.") } - return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) - } - var item = CodexThreadItem( - id: itemID, - kind: kind, - content: content - ) - item.content.appendPreviewText(delta) - snapshot.turns?[turnIndex].items.append(item) - return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) - } - } - - private func updateStoredSnapshot( - for fixture: ReviewMonitorPreviewChatLogFixture, - _ mutation: @escaping @Sendable (inout CodexThreadSnapshot) -> ReviewMonitorPreviewStoredThreadItem? - ) async -> ReviewMonitorPreviewStoredThreadItem? { - await snapshotMutationQueue.run { @MainActor [weak self] in - guard let self, - var snapshot = await self.threadStore.snapshot(id: fixture.chatID), - let item = mutation(&snapshot) else { - return nil + var item = items[itemIndex].domainProjection + item.content.appendPreviewText(delta) + let fixtureItem = try makePreviewTestItem( + id: item.id, + kind: item.kind, + content: item.content, + cwd: fixture.cwd + ) + items[itemIndex] = fixtureItem + turns[turnIndex] = try turns[turnIndex].replacingItems(items) + await self.threadStore.upsert(try stored.replacingTurns(turns)) + return ReviewMonitorPreviewStoredThreadItem( + item: item, + turnID: turns[turnIndex].snapshot.id, + fixtureItem: fixtureItem + ) + } catch { + preconditionFailure("Failed to append Preview stored text: \(error)") } - await self.replaceThreadStorePreservingFixtureOrder( - with: fixture.threadSnapshot(snapshot) - ) - return item } } - private func updateStoredSnapshot( + private func emitItemLifecycle( + _ storedItem: ReviewMonitorPreviewStoredThreadItem, for fixture: ReviewMonitorPreviewChatLogFixture, - mutation: @escaping @Sendable (inout CodexThreadSnapshot) -> Void - ) async -> CodexThreadSnapshot? { - await snapshotMutationQueue.run { @MainActor [weak self] in - guard let self, - var snapshot = await self.threadStore.snapshot(id: fixture.chatID) else { - return nil - } - mutation(&snapshot) - await self.replaceThreadStorePreservingFixtureOrder( - with: fixture.threadSnapshot(snapshot, status: .idle) - ) - return snapshot - } - } - - private func replaceThreadStorePreservingFixtureOrder( - with updatedSnapshot: CodexThreadSnapshot - ) async { - let currentStore = threadStore - let storedSnapshots = await currentStore.snapshots() - let fixtureSnapshotIDs = Set(fixtures.map(\.chatID)) - var orderedFixtureSnapshots: [CodexThreadSnapshot] = [] - for fixture in fixtures { - if await archivedChatIDs.contains(fixture.chatID) { - continue - } - if fixture.chatID == updatedSnapshot.id { - orderedFixtureSnapshots.append(updatedSnapshot) - continue - } - orderedFixtureSnapshots.append( - await currentStore.snapshot(id: fixture.chatID) ?? fixture.threadSnapshot + using runtime: CodexAppServerTestRuntime + ) async throws { + guard let fixtureItem = storedItem.fixtureItem else { + preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") + } + if storedItem.item.isTerminalPreviewItem { + try await runtime.notificationEmitter.emitItemCompleted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem ) - } - let nonFixtureSnapshots = storedSnapshots.filter { snapshot in - fixtureSnapshotIDs.contains(snapshot.id) == false - } - let replacementStore = CodexAppServerTestThreadStore( - threads: orderedFixtureSnapshots + nonFixtureSnapshots - ) - threadStore = replacementStore - do { - if let runtime { - try await rebindRuntimeToCurrentThreadStore(runtime) - } - } catch { - } - } - - private func emitItem( - _ storedItem: ReviewMonitorPreviewStoredThreadItem, - for fixture: ReviewMonitorPreviewChatLogFixture - ) async { - do { - try await ensureStarted() - guard let runtime else { - return - } - await runtime.transport.waitForNotificationStreamCount(1) - await runtime.transport.waitForRequest(method: "thread/read") - try await runtime.transport.emitServerNotification( - method: "item/updated", - params: PreviewThreadItemParams( - threadID: fixture.chatID.rawValue, - turnID: storedItem.turnID.rawValue, - item: makePreviewNotificationItem( - id: storedItem.item.id, - kind: storedItem.item.kind, - content: storedItem.item.content - ) - ) + } else { + try await runtime.notificationEmitter.emitItemStarted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem ) - } catch { } } @@ -587,76 +509,79 @@ final class ReviewMonitorPreviewAppServerRuntime { guard delta.isEmpty == false else { return } - await runtime.transport.waitForNotificationStreamCount(1) - await runtime.transport.waitForRequest(method: "thread/read") if isReasoningDelta(kind: kind, content: content) { - let method: String - let summaryIndex: Int? - let contentIndex: Int? if case .reasoning(let reasoning) = content, reasoning.summary.isEmpty == false { - method = "item/reasoning/summaryTextDelta" - summaryIndex = 0 - contentIndex = nil + try await runtime.notificationEmitter.emitReasoningSummaryTextDelta( + threadID: chatID, + turnID: turnID, + itemID: itemID, + summaryIndex: 0, + delta: delta + ) } else { - method = "item/reasoning/textDelta" - summaryIndex = nil - contentIndex = 0 - } - try await runtime.transport.emitServerNotification( - method: method, - params: PreviewTurnDeltaParams( - threadID: chatID.rawValue, - turnID: turnID.rawValue, + try await runtime.notificationEmitter.emitReasoningTextDelta( + threadID: chatID, + turnID: turnID, itemID: itemID, - delta: delta, - phase: nil, - summaryIndex: summaryIndex, - contentIndex: contentIndex + contentIndex: 0, + delta: delta ) - ) + } return } switch kind { case .commandExecution: - try await emitOutputDelta( - method: "item/commandExecution/outputDelta", - delta: delta, - itemID: itemID, + try await runtime.notificationEmitter.emitCommandExecutionOutputDelta( + threadID: chatID, turnID: turnID, - chatID: chatID, - runtime: runtime + itemID: itemID, + delta: delta ) case .fileChange: - try await emitOutputDelta( - method: "item/fileChange/outputDelta", - delta: delta, - itemID: itemID, + guard case .fileChange(let fileChange) = content, + let path = fileChange.path, + let output = fileChange.output else { + throw CodexAppServerTestError.invalidFixture( + "Preview file-change deltas require a path and accumulated diff." + ) + } + try await runtime.notificationEmitter.emitFileChangePatchUpdated( + threadID: chatID, turnID: turnID, - chatID: chatID, - runtime: runtime + itemID: itemID, + changes: [.init(path: path, kind: .update(movePath: nil), diff: output)] ) - case .mcpToolCall, .dynamicToolCall, .collabAgentToolCall, .subAgentActivity: - try await emitOutputDelta( - method: "item/mcpToolCall/progress", - delta: delta, + case .mcpToolCall: + guard case .toolCall(let toolCall) = content, + let message = toolCall.result else { + throw CodexAppServerTestError.invalidFixture( + "Preview MCP progress requires an accumulated result message." + ) + } + try await runtime.notificationEmitter.emitMCPToolCallProgress( + threadID: chatID, + turnID: turnID, itemID: itemID, + message: message + ) + case .plan: + try await runtime.notificationEmitter.emitPlanDelta( + threadID: chatID, turnID: turnID, - chatID: chatID, - runtime: runtime + itemID: itemID, + delta: delta + ) + case .dynamicToolCall, .collabAgentToolCall, .subAgentActivity: + throw CodexAppServerTestError.invalidFixture( + "Unsupported Preview current-v2 delta kind \(kind.rawValue)." ) default: - try await runtime.transport.emitServerNotification( - method: "item/agentMessage/delta", - params: PreviewTurnDeltaParams( - threadID: chatID.rawValue, - turnID: turnID.rawValue, - itemID: itemID, - delta: delta, - phase: "final_answer", - summaryIndex: nil, - contentIndex: nil - ) + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: chatID, + turnID: turnID, + itemID: itemID, + delta: delta ) } } @@ -674,305 +599,290 @@ final class ReviewMonitorPreviewAppServerRuntime { return false } - private func emitOutputDelta( - method: String, - delta: String, - itemID: String, - turnID: CodexTurnID, - chatID: CodexThreadID, - runtime: CodexAppServerTestRuntime + private func emitCancelledState( + _ storedThread: CodexAppServerTestStoredThread, + for fixture: ReviewMonitorPreviewChatLogFixture, + using runtime: CodexAppServerTestRuntime ) async throws { - try await runtime.transport.emitServerNotification( - method: method, - params: PreviewTurnDeltaParams( - threadID: chatID.rawValue, - turnID: turnID.rawValue, - itemID: itemID, - delta: delta, - phase: nil, - summaryIndex: nil, - contentIndex: nil - ) + try await runtime.notificationEmitter.emitThreadStatusChanged( + threadID: fixture.chatID, + status: .idle ) - } - - private func emitCancelledState( - _ snapshot: CodexThreadSnapshot, - for fixture: ReviewMonitorPreviewChatLogFixture - ) async { - do { - try await ensureStarted() - guard let runtime else { - return - } - await runtime.transport.waitForNotificationStreamCount(1) - let turnID = snapshot.turns?.last?.id ?? fixture.previewFallbackTurnID - try await runtime.transport.emitServerNotification( - method: "thread/status/changed", - params: PreviewThreadStatusParams( - threadID: fixture.chatID.rawValue, - status: .init(type: "idle") - ) - ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: PreviewTurnCompletedParams( - threadID: fixture.chatID.rawValue, - turn: .init( - id: turnID.rawValue, - status: "cancelled", - completedAt: Int(Date().timeIntervalSince1970) - ) - ) - ) - } catch { + guard let turn = storedThread.turns.last else { + preconditionFailure("A cancelled Preview thread must own its interrupted turn.") } + try await runtime.notificationEmitter.emitTurnCompleted( + threadID: fixture.chatID, + turn: turn + ) + turnCompletionNotificationCount += 1 } } -private struct PreviewTurnInterruptParams: Decodable, Sendable { - var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } -} - -private struct PreviewThreadItemParams: Encodable, Sendable { - var threadID: String - var turnID: String - var item: Item - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case item - } - - struct Item: Encodable, Sendable { - var id: String - var type: String - var text: String? - var phase: String? - var command: String? - var cwd: String? - var output: String? - var exitCode: Int? - var status: String? - var path: String? - } -} - -private struct PreviewThreadArchiveParams: Decodable, Sendable { - var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } -} - -private struct PreviewThreadStatusParams: Encodable, Sendable { - var threadID: String - var status: Status - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case status +private func makePreviewStoredThread( + chatID: CodexThreadID, + title: String, + preview: String, + model: String, + modelProvider: String, + workspaceCWD: String, + createdAt: Date, + updatedAt: Date, + recencyAt: Date?, + status: CodexThreadStatus, + cwd: String, + initialThreadSnapshot: CodexThreadSnapshot +) throws -> CodexAppServerTestStoredThread { + guard initialThreadSnapshot.id == chatID else { + throw CodexAppServerTestError.invalidFixture( + "Preview thread identity must match its initial snapshot." + ) } - - struct Status: Encodable, Sendable { - var type: String + let workspace = URL(fileURLWithPath: workspaceCWD, isDirectory: true) + guard model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "Preview stored threads require an explicit model." + ) } -} - -private struct PreviewTurnCompletedParams: Encodable, Sendable { - var threadID: String - var turn: Turn - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turn + guard let initialTurns = initialThreadSnapshot.turns, + initialTurns.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "Preview stored threads require an explicit turn." + ) } - - struct Turn: Encodable, Sendable { - var id: String - var status: String? - var completedAt: Int? + guard modelProvider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "Preview stored threads require an explicit model provider." + ) } -} - -private extension Optional where Wrapped == CodexTurnStatus { - var isTerminalForPreview: Bool { - switch self { - case .completed?, .failed?, .interrupted?, .cancelled?: - true - case .running?, .unknown?, nil: - false + let turns = try initialTurns.map { turn in + let items = try turn.items.map { + try makePreviewTestItem( + id: $0.id, + kind: $0.kind, + content: $0.content, + cwd: cwd + ) } + var snapshot = turn + snapshot.items = items.map(\.domainProjection) + return try CodexAppServerTestTurn(snapshot: snapshot, items: items) } -} - -private struct PreviewTurnDeltaParams: Encodable, Sendable { - var threadID: String - var turnID: String - var itemID: String - var delta: String - var phase: String? - var summaryIndex: Int? - var contentIndex: Int? - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case itemID = "itemId" - case delta - case phase - case summaryIndex - case contentIndex - } -} - -private func makePreviewNotificationItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content -) -> PreviewThreadItemParams.Item { - PreviewThreadItemParams.Item( - id: id, - type: previewNotificationItemType(kind: kind, content: content), - text: previewNotificationText(content), - phase: previewNotificationPhase(content), - command: previewNotificationCommand(content), - cwd: previewNotificationCWD(content), - output: previewNotificationOutput(content), - exitCode: previewNotificationExitCode(content), - status: previewNotificationStatus(content), - path: previewNotificationPath(content) + return try .init( + snapshot: .init( + id: chatID, + workspace: workspace, + name: title, + preview: preview, + modelProvider: modelProvider, + sourceKind: .appServer, + createdAt: createdAt.previewWholeSecondDate, + updatedAt: updatedAt.previewWholeSecondDate, + recencyAt: recencyAt?.previewWholeSecondDate, + status: status, + ephemeral: false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "preview-session-\(chatID.rawValue)", + cliVersion: "codex-preview", + source: .appServer + ), + runtimeMetadata: .init( + model: model, + modelProvider: modelProvider, + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false ) } -private func previewNotificationItemType( +private func makePreviewTestItem( + id: String, kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content -) -> String { - switch content { - case .diagnostic: - "diagnostic" + content: CodexThreadItem.Content, + cwd: String +) throws -> CodexAppServerTestItem { + switch (kind, content) { + case (.userMessage, .message(let message)): + return try .userMessage(id: id, text: message.text) + case (.enteredReviewMode, .log(let review)): + return try .enteredReviewMode(id: id, review: review) + case (.exitedReviewMode, .log(let review)): + return try .exitedReviewMode(id: id, review: review) default: - kind.rawValue + break } -} - -private func previewNotificationText(_ content: CodexThreadItem.Content) -> String? { switch content { case .message(let message): - message.text - case .plan(let text), .diagnostic(let text), .log(let text): - text + return try .agentMessage(id: id, text: message.text, phase: message.phase) + case .plan(let text): + return try .plan(id: id, text: text) case .reasoning(let reasoning): - reasoning.text + return try .reasoning(id: id, summary: reasoning.summary, content: reasoning.content) + case .command(let command): + return try .commandExecution( + id: id, + command: command.command, + cwd: URL(fileURLWithPath: command.cwd ?? cwd, isDirectory: true), + processID: command.processID, + source: .agent, + status: command.status.previewCommandStatus, + aggregatedOutput: command.output, + exitCode: command.exitCode.flatMap(Int32.init(exactly:)), + duration: command.duration + ) + case .fileChange(let fileChange): + let path = fileChange.path ?? URL(fileURLWithPath: cwd, isDirectory: true) + .appendingPathComponent("Preview.patch") + .path + return try .fileChange( + id: id, + changes: [ + CodexFileUpdateChange( + path: path, + kind: .update(movePath: nil), + diff: fileChange.output ?? "" + ) + ], + status: fileChange.status.previewPatchStatus + ) case .toolCall(let toolCall): - toolCall.result ?? toolCall.error ?? toolCall.name + guard let server = toolCall.server, let tool = toolCall.name else { + throw CodexAppServerTestError.invalidFixture( + "Preview MCP tool calls require server and tool names." + ) + } + let result = previewMCPResultComponents(toolCall.result) + return try .mcpToolCall( + id: id, + server: server, + tool: tool, + status: toolCall.status.previewMCPStatus, + resultContent: result.content, + structuredContent: result.structuredContent, + resultMetadata: result.metadata, + errorMessage: toolCall.error + ) case .contextCompaction(let text): - text - case .command, .fileChange, .unknown: - nil + guard text?.isEmpty != false else { + throw CodexAppServerTestError.invalidFixture( + "Current-v2 context-compaction fixtures do not carry display text." + ) + } + return try .contextCompaction(id: id) + case .diagnostic, .log, .unknown: + throw CodexAppServerTestError.invalidFixture( + "Unsupported current-v2 preview item kind \(kind.rawValue)." + ) } } -private func previewNotificationPhase(_ content: CodexThreadItem.Content) -> String? { - if case .message(let message) = content { - return message.phase?.rawValue +func previewMCPResultComponents( + _ flattenedResult: String? +) -> ( + content: [CodexJSONValue]?, + structuredContent: CodexJSONValue?, + metadata: CodexJSONValue? +) { + guard let flattenedResult else { + return (nil, nil, nil) + } + if let data = flattenedResult.data(using: .utf8), + let value = try? JSONDecoder().decode(CodexJSONValue.self, from: data), + case .object(let fields) = value, + case .array(let content)? = fields["content"] { + return ( + content, + fields["structuredContent"]?.nilIfJSONNull, + fields["_meta"]?.nilIfJSONNull + ) } - return nil + return ([.string(flattenedResult)], nil, nil) } -private func previewNotificationCommand(_ content: CodexThreadItem.Content) -> String? { - if case .command(let command) = content { - return command.command +private extension CodexJSONValue { + var nilIfJSONNull: Self? { + self == .null ? nil : self } - return nil } -private func previewNotificationCWD(_ content: CodexThreadItem.Content) -> String? { - if case .command(let command) = content { - return command.cwd +private extension Optional where Wrapped == CodexTurnStatus { + var previewCommandStatus: CodexAppServerTestItem.CommandStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } } - return nil -} -private func previewNotificationOutput(_ content: CodexThreadItem.Content) -> String? { - switch content { - case .command(let command): - return command.output - case .fileChange(let fileChange): - return fileChange.output - default: - return nil + var previewPatchStatus: CodexAppServerTestItem.PatchStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } } -} -private func previewNotificationExitCode(_ content: CodexThreadItem.Content) -> Int? { - if case .command(let command) = content { - return command.exitCode + var previewMCPStatus: CodexAppServerTestItem.MCPStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } } - return nil } -private func previewNotificationStatus(_ content: CodexThreadItem.Content) -> String? { - switch content { - case .command(let command): - command.status?.rawValue - case .fileChange(let fileChange): - fileChange.status?.rawValue - case .toolCall(let toolCall): - toolCall.status?.rawValue - default: - nil +private extension CodexThreadItem { + var isTerminalPreviewItem: Bool { + switch content { + case .command(let command): + command.status != nil && command.status != .inProgress + case .fileChange(let fileChange): + fileChange.status != nil && fileChange.status != .inProgress + case .toolCall(let toolCall): + toolCall.status != nil && toolCall.status != .inProgress + default: + false + } } } -private func previewNotificationPath(_ content: CodexThreadItem.Content) -> String? { - if case .fileChange(let fileChange) = content { - return fileChange.path +private extension CodexTurnSnapshot.State { + var isTerminalForPreview: Bool { + switch self { + case .completed, .failed, .interrupted: + true + case .inProgress, .unknown: + false + } } - return nil } -private extension ReviewMonitorPreviewChatLogFixture { - var threadSnapshot: CodexThreadSnapshot { - threadSnapshot(initialThreadSnapshot) - } - - var previewFallbackTurnID: CodexTurnID { - initialThreadSnapshot.turns?.last?.id ?? CodexTurnID(rawValue: "preview-turn") - } - - func threadSnapshot( - _ snapshot: CodexThreadSnapshot, - status: CodexThreadStatus? = nil - ) -> CodexThreadSnapshot { - CodexThreadSnapshot( - id: chatID, - workspace: workspaceCWD.map { URL(fileURLWithPath: $0, isDirectory: true) }, - name: title, - preview: preview, - modelProvider: model, - updatedAt: updatedAt, - recencyAt: recencyAt, - status: status ?? self.status, - turns: snapshot.turns - ) +private extension Array where Element == CodexAppServerTestTurn { + func requirePreviewTurn() throws -> Index { + guard isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "A Preview stored thread must own an explicit turn." + ) + } + return index(before: endIndex) } } -private extension CodexThreadSnapshot { - mutating func ensurePreviewTurn(fallback turnID: CodexTurnID) -> CodexTurnID { - if let existingTurnID = turns?.last?.id { - return existingTurnID - } - turns = [CodexTurnSnapshot(id: turnID, status: .running)] - return turnID +private extension Date { + var previewWholeSecondDate: Date { + Date(timeIntervalSince1970: timeIntervalSince1970.rounded(.towardZero)) } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index ec7a6cca..d9277ab7 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -1,5 +1,4 @@ import AppKit -import CodexKit import CodexReviewKit import Foundation import ObjectiveC @@ -64,18 +63,6 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( } public extension ReviewMonitorWindowController { - convenience init( - appStore store: CodexReviewStore, - codexModelSource: ReviewMonitorCodexModelSource, - showSettings: @escaping @MainActor () -> Void - ) { - self.init( - store: store, - codexModelSource: codexModelSource, - showSettings: showSettings - ) - } - convenience init( previewContent: ReviewMonitorPreviewContentSource, showSettings: @escaping @MainActor () -> Void diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index b8e3af03..efa0e84a 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,58 +1,117 @@ import Foundation -import CodexKit -@_spi(Testing) import CodexReviewKit +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import CodexReviewKit import ReviewUI @MainActor public final class ReviewMonitorPreviewContentSource { public let store: CodexReviewStore - let runtime: ReviewMonitorPreviewAppServerRuntime + private weak var lifetime: PreviewRuntimeLifetime? - public var codexModelSource: ReviewMonitorCodexModelSource { - runtime.modelSource + package var codexModelSource: ReviewMonitorCodexModelSource { + requireLifetime().modelSource } init( store: CodexReviewStore, - runtime: ReviewMonitorPreviewAppServerRuntime + lifetime: PreviewRuntimeLifetime ) { self.store = store - self.runtime = runtime + self.lifetime = lifetime + } + + isolated deinit { + lifetime?.signalCancellation() } var initialChatID: CodexThreadID? { - runtime.initialChatID + requireLifetime().initialChatID } func start() { - runtime.start() + requireLifetime().start() } func startStreaming(interval: Duration) { - runtime.startStreaming(interval: interval) + requireLifetime().startStreaming(interval: interval) + } + + package func startStreamingForTesting(interval: Duration) { + requireLifetime().startStreaming(interval: interval) + } + + package func cancelStreamingForTesting() { + requireLifetime().cancelStreaming() + } + + package func stopStreamingForTesting() async { + await requireLifetime().stopStreaming() } @discardableResult - public func appendPreviewChatLogStreamTick( + package func appendPreviewChatLogStreamTick( after tick: Int = 0, emitsNotifications: Bool = false ) async -> Int { - await runtime.appendPreviewStreamTick( + await requireLifetime().appendPreviewStreamTick( after: tick, emitsNotifications: emitsNotifications ) } - public func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { - await runtime.snapshotForTesting(chatID: chatID) + package func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { + await requireLifetime().snapshotForTesting(chatID: chatID) + } + + package func observedTurnStateForTesting( + chatID: CodexThreadID + ) -> CodexTurnSnapshot.State? { + requireLifetime().observedTurnStateForTesting(chatID: chatID) + } + + package func interruptRequestCountForTesting() async -> Int { + await requireLifetime().interruptRequestCountForTesting() + } + + package func turnCompletionNotificationCountForTesting() -> Int { + requireLifetime().turnCompletionNotificationCountForTesting() + } + + package func archiveRequestCountForTesting() async -> Int { + await requireLifetime().archiveRequestCountForTesting() } - public func interruptRequestCountForTesting() async -> Int { - await runtime.interruptRequestCountForTesting() + package func startAndWaitForTesting() async { + await requireLifetime().startAndWaitForTesting() } - public func archiveRequestCountForTesting() async -> Int { - await runtime.archiveRequestCountForTesting() + var runtimeLifetimeForTesting: PreviewRuntimeLifetime? { + lifetime + } + + package var runtimeTransportForTesting: CodexAppServerTestTransport? { + lifetime?.runtimeTransportForTesting + } + + package var modelContainerForTesting: CodexModelContainer? { + lifetime?.modelContainerForTesting + } + + package var activeRuntimeTaskCountForTesting: Int { + lifetime?.activeTaskCountForTesting ?? 0 + } + + package var stopOperationCountForTesting: Int { + lifetime?.stopOperationCountForTesting ?? 0 + } + + private func requireLifetime() -> PreviewRuntimeLifetime { + guard let lifetime else { + preconditionFailure("A preview content source requires its store-owned runtime lifetime.") + } + return lifetime } } @@ -110,7 +169,7 @@ public enum ReviewMonitorPreviewContent { itemName: String? = nil, kind: CodexThreadItem.Kind, content: CodexThreadItem.Content, - mode: PreviewStreamMode = .complete, + mode: PreviewStreamMode, deltaText: String? = nil, chunkByWord: Bool = false, delayBeforeFrameCount: Int, @@ -127,7 +186,7 @@ public enum ReviewMonitorPreviewContent { } } - package enum PreviewStreamMode { + package enum PreviewStreamMode: Sendable { case update case complete case textDelta @@ -152,12 +211,12 @@ public enum ReviewMonitorPreviewContent { CodexThreadItem( id: id, kind: kind, - content: content + content: content.rebindingMessageID(to: id) ) } } - package struct PreviewChatLogStreamStep { + package struct PreviewChatLogStreamStep: Sendable { let itemName: String let kind: CodexThreadItem.Kind let content: CodexThreadItem.Content @@ -168,19 +227,26 @@ public enum ReviewMonitorPreviewContent { CodexThreadItem( id: id, kind: kind, - content: content + content: content.rebindingMessageID(to: id) ) } } - package struct PreviewStreamFrame { + package struct PreviewStreamFrame: Sendable { let step: PreviewChatLogStreamStep let cycle: Int } - public static func makeStore() -> CodexReviewStore { + package static func makeStore() -> CodexReviewStore { + makeStore(runtimeLifetime: nil) + } + + private static func makeStore( + runtimeLifetime: PreviewRuntimeLifetime? + ) -> CodexReviewStore { let store = CodexReviewStore.makePreviewStore( - seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) + seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()), + runtimeLifetime: runtimeLifetime ) let accounts = makePreviewAccounts() store.loadForTesting( @@ -194,28 +260,23 @@ public enum ReviewMonitorPreviewContent { public static func makeContentSource() -> ReviewMonitorPreviewContentSource { let chatLogFixtures = makeChatLogFixtures() - let store = makeStore() - let previewRuntime = ReviewMonitorPreviewAppServerRuntime( + let lifetime = PreviewRuntimeLifetime( fixtures: chatLogFixtures ) return ReviewMonitorPreviewContentSource( - store: store, - runtime: previewRuntime + store: makeStore(runtimeLifetime: lifetime), + lifetime: lifetime ) } - public static func makeCommandOutputStore() -> CodexReviewStore { + package static func makeCommandOutputStore() -> CodexReviewStore { makeCommandOutputContentSource().store } - public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { - let store = CodexReviewStore.makePreviewStore( - seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) - ) - let accounts = makePreviewAccounts() + package static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { let cwd = "/path/to/workspace-alpha" let now = Date() - let chatItems = makeCommandOutputPreviewChatLogItems() + let chatItems = makeCommandOutputPreviewChatLogItems(cwd: cwd) let chatID = CodexThreadID(rawValue: "preview-command-output-panel") let turnID = CodexTurnID(rawValue: "preview-command-output-turn") let chatFixture = PreviewChatFixture( @@ -232,18 +293,14 @@ public enum ReviewMonitorPreviewContent { endedAt: nil, chatItems: chatItems ) - store.loadForTesting( - serverState: .running, - account: accounts.first, - persistedAccounts: accounts, - serverURL: URL(string: "http://localhost:9417/mcp") - ) let fixture = makeChatLogFixture( - for: chatFixture + for: chatFixture, + referenceDate: now ) + let lifetime = PreviewRuntimeLifetime(fixtures: [fixture]) return ReviewMonitorPreviewContentSource( - store: store, - runtime: ReviewMonitorPreviewAppServerRuntime(fixtures: [fixture]) + store: makeStore(runtimeLifetime: lifetime), + lifetime: lifetime ) } @@ -297,6 +354,16 @@ public enum ReviewMonitorPreviewContent { let itemName = template.itemName ?? "stream-\(templateIndex)" let streamText = template.deltaText ?? "" let chunks = template.chunkByWord ? wordChunks(in: streamText) : [streamText] + if case .textDelta = template.mode { + schedule.append( + PreviewChatLogStreamStep( + itemName: itemName, + kind: template.kind, + content: template.content, + mode: .update, + deltaText: nil + )) + } for (index, chunk) in chunks.enumerated() { if index > 0 && template.chunkIntervalFrameCount > 1 { schedule.append(contentsOf: Array(repeating: nil, count: template.chunkIntervalFrameCount - 1)) @@ -334,8 +401,13 @@ public enum ReviewMonitorPreviewContent { private static let previewStreamTemplates: [PreviewStreamTemplate] = [ .init( - kind: CodexThreadItem.Kind(rawValue: "event"), - content: .diagnostic("Turn started: \(previewTurnID(1))"), + kind: .agentMessage, + content: .message(.init( + id: "turn-started", + role: .assistant, + text: "Turn started: \(previewTurnID(1))" + )), + mode: .complete, delayBeforeFrameCount: 1 ), .init( @@ -347,6 +419,7 @@ public enum ReviewMonitorPreviewContent { [in_progress] Preserve active find UI while streaming [pending] Run focused UI tests """), + mode: .complete, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -356,7 +429,7 @@ public enum ReviewMonitorPreviewContent { .init( command: "/bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"", - status: .running + status: .inProgress )), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount @@ -366,7 +439,8 @@ public enum ReviewMonitorPreviewContent { kind: .commandExecution, content: .command( .init( - command: "", + command: + "/bin/zsh -lc \"rg -n 'ReviewMonitorLog' Sources/ReviewUI && swift test --filter ReviewUI\"", output: """ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift:42: private let logDocumentView = ReviewMonitorLogDocumentView() Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift:20: final class ReviewMonitorLogDocumentView @@ -375,15 +449,19 @@ public enum ReviewMonitorPreviewContent { exitCode: 0, status: .completed )), + mode: .complete, delayBeforeFrameCount: commandCompletionDelayFrameCount ), .init( kind: .mcpToolCall, content: .toolCall( .init( + server: "codex_review", + name: "review_read", result: "MCP codex_review.review_read started.", - status: .running + status: .inProgress )), + mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -403,7 +481,7 @@ public enum ReviewMonitorPreviewContent { .init( command: "/bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"", - status: .running + status: .inProgress )), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount @@ -413,7 +491,8 @@ public enum ReviewMonitorPreviewContent { kind: .commandExecution, content: .command( .init( - command: "", + command: + "/bin/zsh -lc \"sed -n '1,240p' Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift\"", output: """ import AppKit import ObjectiveC.runtime @@ -426,28 +505,41 @@ public enum ReviewMonitorPreviewContent { exitCode: 0, status: .completed )), + mode: .complete, delayBeforeFrameCount: commandCompletionDelayFrameCount ), .init( itemName: "context-compaction", - kind: .contextCompaction, - content: .contextCompaction("Automatically compacting context"), + kind: .agentMessage, + content: .message(.init( + id: "context-compaction", + role: .assistant, + text: "Automatically compacting context" + )), mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( itemName: "context-compaction", - kind: .contextCompaction, - content: .contextCompaction("Context automatically compacted"), + kind: .agentMessage, + content: .message(.init( + id: "context-compaction", + role: .assistant, + text: "Context automatically compacted" + )), + mode: .complete, delayBeforeFrameCount: compactionCompletionDelayFrameCount ), .init( kind: .mcpToolCall, content: .toolCall( .init( + server: "codex_review", + name: "review_read", result: "File changes updated.", status: .completed )), + mode: .complete, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -480,13 +572,16 @@ public enum ReviewMonitorPreviewContent { text: "The preview stream now mixes commands, tool events, reasoning summaries, and visible assistant output instead of one repeated message kind.\n", )), + mode: .complete, delayBeforeFrameCount: interItemDelayFrameCount ), ] private static let previewChatLogStreamSchedule = chatLogStreamSchedule(from: previewStreamTemplates) - private static func makeCommandOutputPreviewChatLogItems() -> [PreviewChatLogItemTemplate] { + private static func makeCommandOutputPreviewChatLogItems( + cwd: String + ) -> [PreviewChatLogItemTemplate] { let output = """ Command line invocation: /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor @@ -502,10 +597,9 @@ public enum ReviewMonitorPreviewContent { Test Suite 'Selected tests' passed. """ return [ - diagnosticItem( + messageItem( "command-output-event", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Turn started: preview-command-output-panel" + text: "Turn started: preview-command-output-panel" ), messageItem( "command-output-intro", @@ -520,10 +614,14 @@ public enum ReviewMonitorPreviewContent { commandStartedItem( "preview-command-output", command: - "xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor" + "xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor", + cwd: cwd ), commandCompletedItem( "preview-command-output", + command: + "xcodebuild test -project Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj -scheme CodexReviewMonitor", + cwd: cwd, output: output, exitCode: 0, status: .completed @@ -533,7 +631,7 @@ public enum ReviewMonitorPreviewContent { ] } - public static func makePreviewAccounts() -> [CodexReviewAccount] { + package static func makePreviewAccounts() -> [CodexReviewAccount] { [ makePreviewAccount( email: "workspace@example.com", @@ -550,7 +648,7 @@ public enum ReviewMonitorPreviewContent { ] } - public static func makePreviewAccount( + package static func makePreviewAccount( email: String = "review@example.com", usedPercents: (short: Int, long: Int) = (short: 34, long: 61) ) -> CodexReviewAccount { @@ -636,7 +734,11 @@ public enum ReviewMonitorPreviewContent { for (workspaceIndex, cwd) in workspacePaths.enumerated() { let workspaceName = URL(fileURLWithPath: cwd).lastPathComponent for (chatIndex, definition) in makeChatDefinitions(for: workspaceName).enumerated() { - let chatItems = makePreviewChatLogItems(for: definition, workspaceName: workspaceName) + let chatItems = makePreviewChatLogItems( + for: definition, + workspaceName: workspaceName, + cwd: cwd + ) let chatID = CodexThreadID(rawValue: "preview-thread-\(workspaceIndex)-\(chatIndex)") let turnID = CodexTurnID(rawValue: "preview-turn-\(workspaceIndex)-\(chatIndex)") let chatFixture = PreviewChatFixture( @@ -655,7 +757,8 @@ public enum ReviewMonitorPreviewContent { ) chatLogFixtures.append( makeChatLogFixture( - for: chatFixture + for: chatFixture, + referenceDate: now )) } } @@ -663,12 +766,15 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( - for chatFixture: PreviewChatFixture + for chatFixture: PreviewChatFixture, + referenceDate: Date ) -> ReviewMonitorPreviewChatLogFixture { let turn = CodexTurnSnapshot( id: chatFixture.turnID, - status: CodexTurnStatus(chatFixture.lifecycle), - errorMessage: chatFixture.lifecycle == .failed ? chatFixture.summary : nil, + state: CodexTurnSnapshot.State( + chatFixture.lifecycle, + failureMessage: chatFixture.summary + ), items: makeInitialChatItems( streamID: chatFixture.id, chatItems: chatFixture.chatItems @@ -681,11 +787,15 @@ public enum ReviewMonitorPreviewContent { return ReviewMonitorPreviewChatLogFixture( chatID: chatFixture.chatID, title: chatFixture.targetSummary, - preview: chatFixture.initialMessage.nilIfEmpty ?? chatFixture.summary.nilIfEmpty, + preview: chatFixture.initialMessage.nilIfEmpty + ?? chatFixture.summary.nilIfEmpty + ?? chatFixture.targetSummary, model: chatFixture.model, + modelProvider: "openai", workspaceCWD: chatFixture.cwd, - updatedAt: chatFixture.endedAt ?? chatFixture.startedAt, - recencyAt: chatFixture.endedAt ?? chatFixture.startedAt, + createdAt: chatFixture.startedAt ?? referenceDate, + updatedAt: chatFixture.endedAt ?? chatFixture.startedAt ?? referenceDate, + recencyAt: chatFixture.endedAt ?? chatFixture.startedAt ?? referenceDate, status: CodexThreadStatus(previewLifecycle: chatFixture.lifecycle), cwd: chatFixture.cwd, streamID: chatFixture.id, @@ -718,18 +828,6 @@ public enum ReviewMonitorPreviewContent { return items } - private static func diagnosticItem( - _ itemName: String, - kind: CodexThreadItem.Kind, - message: String - ) -> PreviewChatLogItemTemplate { - .init( - itemName: itemName, - kind: kind, - content: .diagnostic(message) - ) - } - private static func messageItem(_ itemName: String, text: String) -> PreviewChatLogItemTemplate { .init( itemName: itemName, @@ -758,51 +856,70 @@ public enum ReviewMonitorPreviewContent { ) } - private static func contextCompactionItem( - _ itemName: String, - title: String - ) -> PreviewChatLogItemTemplate { + private static func contextCompactionItem(_ itemName: String) -> PreviewChatLogItemTemplate { .init( itemName: itemName, kind: .contextCompaction, - content: .contextCompaction(title) + content: .contextCompaction(nil) ) } private static func commandStartedItem( _ itemName: String, command: String, - cwd: String? = nil + cwd: String ) -> PreviewChatLogItemTemplate { - .init( - itemName: itemName, - kind: .commandExecution, - content: .command( - .init( - command: command, - cwd: cwd, - status: .running - )) - ) + do { + let fixture = try CodexAppServerTestItem.commandExecution( + id: itemName, + command: command, + cwd: URL(fileURLWithPath: cwd, isDirectory: true), + status: .inProgress + ) + return .init( + itemName: itemName, + kind: fixture.domainProjection.kind, + content: fixture.domainProjection.content + ) + } catch { + preconditionFailure("Invalid Preview command fixture: \(error)") + } } private static func commandCompletedItem( _ itemName: String, + command: String, + cwd: String, output: String, exitCode: Int, status: CodexTurnStatus ) -> PreviewChatLogItemTemplate { - .init( - itemName: itemName, - kind: .commandExecution, - content: .command( - .init( - command: "", - output: output, - exitCode: exitCode, - status: status - )) - ) + let fixtureStatus: CodexAppServerTestItem.CommandStatus + switch status { + case .completed: + fixtureStatus = .completed + case .failed: + fixtureStatus = .failed + case .inProgress, .interrupted, .unknown: + preconditionFailure("Invalid terminal Preview command status \(status.rawValue).") + } + do { + let fixture = try CodexAppServerTestItem.commandExecution( + id: itemName, + command: command, + cwd: URL(fileURLWithPath: cwd, isDirectory: true), + status: fixtureStatus, + aggregatedOutput: output, + exitCode: Int32(exactly: exitCode) + ) + return .init( + itemName: itemName, + kind: fixture.domainProjection.kind, + content: fixture.domainProjection.content + ) + } catch { + preconditionFailure("Invalid Preview command fixture: \(error)") + } } private static func toolCallItem( @@ -810,47 +927,74 @@ public enum ReviewMonitorPreviewContent { result: String, status: CodexTurnStatus ) -> PreviewChatLogItemTemplate { - return PreviewChatLogItemTemplate( - itemName: itemName, - kind: .mcpToolCall, - content: .toolCall(.init(result: result, status: status)) - ) + let fixtureStatus: CodexAppServerTestItem.MCPStatus + switch status { + case .inProgress: + fixtureStatus = .inProgress + case .completed: + fixtureStatus = .completed + case .failed: + fixtureStatus = .failed + case .interrupted, .unknown: + preconditionFailure("Invalid Preview MCP status \(status.rawValue).") + } + do { + let fixture = try CodexAppServerTestItem.mcpToolCall( + id: itemName, + server: "codex_review", + tool: "review_start", + status: fixtureStatus, + resultContent: [.string(result)] + ) + return .init( + itemName: itemName, + kind: fixture.domainProjection.kind, + content: fixture.domainProjection.content + ) + } catch { + preconditionFailure("Invalid Preview MCP fixture: \(error)") + } } private static func makePreviewChatLogItems( for definition: PreviewChatDefinition, - workspaceName: String + workspaceName: String, + cwd: String ) -> [PreviewChatLogItemTemplate] { switch definition.lifecycle { case .running: - return makeRunningPreviewChatLogItems(for: definition, workspaceName: workspaceName) + return makeRunningPreviewChatLogItems( + for: definition, + workspaceName: workspaceName, + cwd: cwd + ) case .queued: return [ - diagnosticItem( + messageItem( "queued-event-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Queued review for \(definition.targetSummary)." + text: "Queued review for \(definition.targetSummary)." ), - diagnosticItem( + messageItem( "queued-progress-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "progress"), - message: definition.summary + text: definition.summary ), ] case .failed: let commandName = "preview-failed-command-\(workspaceName)-\(definition.targetSummary)" return [ - diagnosticItem( + messageItem( "failed-event-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Turn started: preview-failed-\(workspaceName.lowercased())" + text: "Turn started: preview-failed-\(workspaceName.lowercased())" ), commandStartedItem( commandName, - command: "/bin/zsh -lc \"swift test --build-system swiftbuild --no-parallel\"" + command: "/bin/zsh -lc \"swift test --build-system swiftbuild --no-parallel\"", + cwd: cwd ), commandCompletedItem( commandName, + command: "/bin/zsh -lc \"swift test --build-system swiftbuild --no-parallel\"", + cwd: cwd, output: """ Building for debugging... Test Suite 'ReviewUITests' started. @@ -859,25 +1003,22 @@ public enum ReviewMonitorPreviewContent { exitCode: 1, status: .failed ), - diagnosticItem( + messageItem( "failed-error-\(workspaceName)-\(definition.targetSummary)", - kind: .error, - message: definition.summary + text: definition.summary ), messageItem( "failed-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), ] case .cancelled: return [ - diagnosticItem( + messageItem( "cancelled-event-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Turn started: preview-cancelled-\(workspaceName.lowercased())" + text: "Turn started: preview-cancelled-\(workspaceName.lowercased())" ), - diagnosticItem( + messageItem( "cancelled-progress-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "progress"), - message: definition.summary + text: definition.summary ), messageItem( "cancelled-message-\(workspaceName)-\(definition.targetSummary)", text: definition.initialMessage), @@ -885,17 +1026,19 @@ public enum ReviewMonitorPreviewContent { case .succeeded: let commandName = "preview-complete-command-\(workspaceName)-\(definition.targetSummary)" return [ - diagnosticItem( + messageItem( "complete-event-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Turn started: preview-complete-\(workspaceName.lowercased())" + text: "Turn started: preview-complete-\(workspaceName.lowercased())" ), commandStartedItem( commandName, - command: "/bin/zsh -lc \"swift test --filter ReviewUI\"" + command: "/bin/zsh -lc \"swift test --filter ReviewUI\"", + cwd: cwd ), commandCompletedItem( commandName, + command: "/bin/zsh -lc \"swift test --filter ReviewUI\"", + cwd: cwd, output: """ Test Suite 'ReviewUITests' started. Test commandOutputRendersCollapsedTextKitPanelAndExpandsInline passed. @@ -916,26 +1059,24 @@ public enum ReviewMonitorPreviewContent { private static func makeRunningPreviewChatLogItems( for definition: PreviewChatDefinition, - workspaceName: String + workspaceName: String, + cwd: String ) -> [PreviewChatLogItemTemplate] { let sourceReadItemName = "preview-initial-source-read-\(workspaceName)-\(definition.targetSummary)" let sourceReadCommand = "sed -n '1,260p' Sources/ReviewUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift" let initialCommandName = "preview-initial-command-\(workspaceName)-\(definition.targetSummary)" return [ - diagnosticItem( + messageItem( "running-event-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "event"), - message: "Turn started: preview-\(workspaceName.lowercased())" + text: "Turn started: preview-\(workspaceName.lowercased())" ), - diagnosticItem( + messageItem( "running-progress-\(workspaceName)-\(definition.targetSummary)", - kind: CodexThreadItem.Kind(rawValue: "progress"), - message: "Reviewing \(definition.targetSummary)" + text: "Reviewing \(definition.targetSummary)" ), contextCompactionItem( - "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)", - title: "Context automatically compacted" + "preview-initial-context-compaction-\(workspaceName)-\(definition.targetSummary)" ), planItem( "preview-initial-plan-\(workspaceName)-\(definition.targetSummary)", @@ -947,10 +1088,13 @@ public enum ReviewMonitorPreviewContent { ), commandStartedItem( initialCommandName, - command: "/bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\"" + command: "/bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\"", + cwd: cwd ), commandCompletedItem( initialCommandName, + command: "/bin/zsh -lc \"git diff --stat && rg -n 'ReviewMonitor' Sources Tests\"", + cwd: cwd, output: """ Sources/ReviewUI/Detail/ReviewMonitorLogScrollView.swift | 34 +++++++++++++++++ Sources/ReviewUI/Detail/ReviewMonitorLogDocumentView.swift | 18 ++++++++-- @@ -964,12 +1108,13 @@ public enum ReviewMonitorPreviewContent { ), commandStartedItem( sourceReadItemName, - command: "/bin/zsh -lc \"\(sourceReadCommand)\"" + command: "/bin/zsh -lc \"\(sourceReadCommand)\"", + cwd: cwd ), toolCallItem( "running-tool-\(workspaceName)-\(definition.targetSummary)", result: "MCP codex_review.review_start started.", - status: .running + status: .inProgress ), reasoningItem( "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)", @@ -1061,28 +1206,30 @@ private extension CodexThreadStatus { } } -private extension CodexTurnStatus { - init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { - switch lifecycle { - case .queued, .running: - self = .running - case .succeeded: - self = .completed - case .failed: - self = .failed - case .cancelled: - self = .cancelled +private extension CodexThreadItem.Content { + func rebindingMessageID(to id: String) -> Self { + guard case .message(var message) = self else { + return self } + message.id = id + return .message(message) } } -private extension CodexDataPhase { - init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) { +private extension CodexTurnSnapshot.State { + init( + _ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, + failureMessage: String + ) { switch lifecycle { - case .queued, .running, .succeeded, .cancelled: - self = .loaded + case .queued, .running: + self = .inProgress + case .succeeded: + self = .completed case .failed: - self = .failed(errorMessage ?? "Review failed") + self = .failed(CodexTurnError(message: failureMessage)) + case .cancelled: + self = .interrupted } } } diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index bd00a8d2..171c4867 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -2,573 +2,940 @@ import Foundation import Testing import CodexAppServerKit import CodexAppServerKitTesting +import CodexDataKit @testable import CodexReviewAppServer import CodexReviewKit -private struct BackendReviewEventSequence: AsyncSequence { - struct AsyncIterator: AsyncIteratorProtocol { - var mailbox: BackendReviewEventMailbox - - mutating func next() async throws -> CodexReviewBackendModel.Review.Event? { - try await mailbox.next() - } - } - - var mailbox: BackendReviewEventMailbox - - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator(mailbox: mailbox) - } -} - @Suite("AppServerClientTests") struct AppServerClientTests { @Test func backendStartsReviewThroughExternalCodexKit() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .uncommittedChanges)) - #expect(attempt.run.threadID == "thread-1") - #expect(attempt.run.turnID == "turn-1") - #expect(attempt.run.reviewThreadID == "thread-1") + #expect(attempt.attempt.threadIdentity.sourceThreadID.rawValue == "thread-1") + #expect(attempt.attempt.turnID.rawValue == "turn-1") + #expect(attempt.attempt.threadIdentity.activeTurnThreadID.rawValue == "thread-1") let requests = await runtime.transport.recordedRequests() - #expect(requests.map(\.method) == ["initialize", "thread/start", "review/start"]) - - let threadStart = try #require(requests.first { $0.method == "thread/start" }) - let threadParams = try jsonObject(from: threadStart.params) - #expect(threadParams["cwd"] as? String == "/tmp/project") - #expect(threadParams["model"] as? String == "gpt-5") - #expect(threadParams["ephemeral"] as? Bool == false) - #expect(threadParams["approvalPolicy"] as? String == "never") - #expect(threadParams["permissions"] as? String == ":danger-full-access") - #expect(threadParams["sessionStartSource"] as? String == "startup") - #expect(threadParams["threadSource"] as? String == "user") - #expect(threadParams["sandbox"] == nil) - - let reviewStart = try #require(requests.first { $0.method == "review/start" }) - let reviewParams = try jsonObject(from: reviewStart.params) - #expect(reviewParams["threadId"] as? String == "thread-1") - #expect(reviewParams["delivery"] as? String == "inline") - let target = try #require(reviewParams["target"] as? [String: Any]) - #expect(target["type"] as? String == "uncommittedChanges") + #expect(requests.map(\.request.operation) == [ + .initialize, + .threadStart, + .reviewStart, + ]) + + let threadStart = try #require(requests.compactMap { request + -> (URL, CodexInstructions?, CodexThread.Options)? in + guard case .threadStart(let workspace, let instructions, let options) = request.request + else { return nil } + return (workspace, instructions, options) + }.first) + #expect(threadStart.0.path == "/tmp/project") + #expect(threadStart.1 == nil) + #expect(threadStart.2.model == "gpt-5") + #expect(threadStart.2.ephemeral == false) + #expect(threadStart.2.approvalMode == .denyAll) + #expect(threadStart.2.permissions == .profile(id: ":danger-full-access")) + #expect(threadStart.2.sessionStartSource == .startup) + #expect(threadStart.2.threadSource == .user) + #expect(threadStart.2.sandbox == nil) + + let reviewStart = try #require(requests.compactMap { request + -> (CodexThreadID, CodexReviewTarget, CodexReviewDelivery)? in + guard case .reviewStart(let threadID, let target, let delivery) = request.request + else { return nil } + return (threadID, target, delivery) + }.first) + #expect(reviewStart.0 == "thread-1") + #expect(reviewStart.1 == .uncommittedChanges) + #expect(reviewStart.2 == .inline) } @Test func backendConsumesTypedReviewSessionStream() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() - try await runtime.transport.emitServerNotification( - method: "item/completed", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - type: "commandExecution", - id: "cmd-1", - command: "swift test", - aggregatedOutput: "passed", - status: "completed" - ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "thread-1", + turnID: "turn-1", + item: .commandExecution( + id: "cmd-1", + command: "swift test", + cwd: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + status: .completed, + aggregatedOutput: "passed" ) ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init( - id: "turn-1", - status: "completed", - items: [ - .exitedReviewMode(id: "review-output", review: "Looks good.") - ] - ) - ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + items: [ + .exitedReviewMode(id: "review-output", review: "Looks good."), + ] + ) + + let observed = try await withTimeout { + try await attempt.observeTerminal() + } + let turnID = try makeTurnID("turn-1") + #expect( + observed == .completed(.init( + turnID: turnID, + expectedOutput: try NonEmptyReviewOutput(validating: "Looks good.") + )) ) + #expect(await runtime.transport.recordedRequests(for: .threadRead).isEmpty) + + try await enqueueReviewProjection( + transport: runtime.transport, + threadID: "thread-1", + turnID: "turn-1", + output: "Looks good." + ) + #expect( + await attempt.finalizeTerminal(observed) + == .completed(.init( + finalReview: try NonEmptyReviewOutput(validating: "Looks good.") + )) + ) + #expect(await runtime.transport.recordedRequests(for: .threadRead).count == 1) + } + + @Test func completionPublicationFinishesAfterFinalizerCallerCancellation() async throws { + let fixture = try await makeCompletedReviewFixture(output: "Looks good.") + try await enqueueReviewProjection( + transport: fixture.runtime.transport, + threadID: "thread-1", + turnID: "turn-1", + output: "Looks good." + ) + let refreshGate = CodexAppServerTestGate() + await fixture.runtime.transport.holdNextIgnoringCancellation( + .threadRead, + gate: refreshGate + ) + + let finalization = Task { + await fixture.attempt.finalizeTerminal(fixture.observed) + } + await fixture.runtime.transport.waitForRequest(.threadRead) + await refreshGate.waitUntilBlocked() + finalization.cancel() + await refreshGate.open() #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed(finalReview: "Looks good.")) + await finalization.value + == .completed(.init( + finalReview: try NonEmptyReviewOutput(validating: "Looks good.") + )) + ) + #expect( + await fixture.runtime.transport.recordedRequests(for: .threadRead).count == 1 + ) + } + + @Test(arguments: ReviewPublicationFailureScenario.allCases) + func completionPublicationMapsTypedFailure( + _ scenario: ReviewPublicationFailureScenario + ) async throws { + let fixture = try await makeCompletedReviewFixture(output: "Expected output") + switch scenario { + case .turnUnavailable: + try await enqueueReviewProjection( + transport: fixture.runtime.transport, + threadID: "thread-1", + turnID: nil, + output: nil + ) + case .outputMissing: + try await enqueueReviewProjection( + transport: fixture.runtime.transport, + threadID: "thread-1", + turnID: "turn-1", + output: nil + ) + case .outputEmpty: + try await enqueueReviewProjection( + transport: fixture.runtime.transport, + threadID: "thread-1", + turnID: "turn-1", + output: " " + ) + case .outputMismatch: + try await enqueueReviewProjection( + transport: fixture.runtime.transport, + threadID: "thread-1", + turnID: "turn-1", + output: "Different output" + ) + case .refreshFailure: + try await fixture.runtime.transport.enqueueFailure( + .response(code: -32_000, message: "projection unavailable"), + for: .threadRead + ) + } + + let terminal = await fixture.attempt.finalizeTerminal(fixture.observed) + guard case .failed(.outputPublication(let failure)) = terminal else { + Issue.record("Expected a typed output-publication failure, got \(terminal).") + return + } + let turnID = try makeTurnID("turn-1") + switch (scenario, failure) { + case (.turnUnavailable, .unavailable(let actualTurnID)): + #expect(actualTurnID == turnID) + case (.outputMissing, .empty(let actualTurnID)), + (.outputEmpty, .empty(let actualTurnID)): + #expect(actualTurnID == turnID) + case (.outputMismatch, .mismatched(let actualTurnID)): + #expect(actualTurnID == turnID) + case (.refreshFailure, .refreshFailed(let actualTurnID, let message)): + #expect(actualTurnID == turnID) + #expect(message.contains("projection unavailable")) + default: + Issue.record("Unexpected publication mapping \(failure) for \(scenario).") + } + #expect( + await fixture.runtime.transport.recordedRequests(for: .threadRead).count == 1 + ) } @Test func backendCompletesReviewFromExitedReviewModeWhenTerminalPayloadIsSparse() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() - try await runtime.transport.emitServerNotification( - method: "item/completed", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - type: "exitedReviewMode", - id: "review-output", - review: "No issues found." - ) - ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "thread-1", + turnID: "turn-1", + item: .exitedReviewMode(id: "review-output", review: "No issues found.") ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + itemsLoadState: .notLoaded ) #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed(finalReview: "No issues found.")) + try await attempt.observeTerminal() + == .completed(.init( + turnID: makeTurnID("turn-1"), + expectedOutput: try NonEmptyReviewOutput(validating: "No issues found.") + )) + ) } - @Test func backendCompletesReviewFromCompatibleFinalAnswerWhenReviewOutputIsAbsent() async throws { + @Test func backendSurfacesTransportContractViolationAsConnectionTermination() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() - - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init( - id: "turn-1", - status: "completed", - items: [ - .finalAnswer(id: "assistant-final", text: "No issues found.") - ] - ) - ) - ) - #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed(finalReview: "No issues found.")) + await runtime.transport.failConnection(.contractViolation( + message: "Current-v2 notification is missing required field turnId." + )) + let observed = try await attempt.observeTerminal() + guard case .failed(.connectionTerminated(.transport(let message))) = observed else { + Issue.record( + "Expected a typed connection termination for the malformed stream, got \(observed)." + ) + return + } + #expect(message.contains("Current-v2 notification is missing required field turnId.")) } - @Test func backendDoesNotPromoteThreadScopedFinalAnswerDeltaWithoutTurnID() async throws { + @Test func backendDoesNotPromoteAgentMessageToInlineReview() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() - - try await runtime.transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TestDeltaWithoutTurnIDNotification( - threadID: "review-thread", - itemID: "msg-1", - delta: "Looks good.", - phase: "final_answer" - ) + + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "thread-1", + turnID: "turn-1", + item: .agentMessage(id: "message-1", text: "Looks good.") ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed ) #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .failed("Review completed without review output.")) + try await attempt.observeTerminal() + == .failed(.missingReviewOutput(turnID: makeTurnID("turn-1")))) } - @Test func backendDoesNotPromoteThreadlessAgentMessageToInlineReview() async throws { + @Test func backendFailsCompletedReviewWithoutReviewOutput() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() - try await runtime.transport.emitServerNotification( - method: "agent/message", - params: TestAgentMessageNotification(message: "Looks good.") + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "thread-1", - turn: .init(id: "turn-1", status: "completed") - ) + + #expect( + try await attempt.observeTerminal() + == .failed(.missingReviewOutput(turnID: makeTurnID("turn-1")))) + } + + @Test func backendPreservesTypedTurnFailure() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = await makeBackend(appServer: runtime.server) + + let attempt = try await backend.startReview(makeReviewStart()) + + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .failed(.init( + message: "Capacity exhausted.", + info: .serverOverloaded, + additionalDetails: "retry after the maintenance window" + )) ) #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "thread-1", model: "gpt-5")) - #expect(try await iterator.next() == .failed("Review completed without review output.")) + try await attempt.observeTerminal() + == .failed( + .turnFailed( + .init( + message: "Capacity exhausted.", + code: .serverOverloaded, + additionalDetails: "retry after the maintenance window" + ) + ) + ) + ) } - @Test func backendFailsCompletedReviewWithoutReviewOutput() async throws { + @Test func backendKeepsServerInterruptionDistinctFromCallerCancellation() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) - let attempt = try await backend.startReview(makeReviewStart(target: .baseBranch("main"))) - var iterator = eventSequence(attempt).makeAsyncIterator() + let attempt = try await backend.startReview(makeReviewStart()) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .interrupted + ) + + #expect(try await attempt.observeTerminal() == .interrupted(message: nil)) + } + + @Test func terminalMapperPreservesUnknownTerminalStatusError() throws { + let attempt = try makeReviewAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "thread-1" + ) + let outcome = CodexTurnOutcome.invalidTerminalStatus( + rawStatus: "pausedByFutureServer", + error: .init( + message: "Future terminal detail.", + info: .unknown(rawValue: "futureErrorCode"), + additionalDetails: "future additional detail" + ), + response: .init(turnID: "turn-1") ) + let expectedTurnID = try makeTurnID("turn-1") #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .failed("Review completed without review output.")) + AppServerReviewTerminalMapper.observed( + outcome, + expectedAttempt: attempt + ) + == .failed( + .invalidTerminalStatus( + rawStatus: "pausedByFutureServer", + turnID: expectedTurnID, + turnFailure: .init( + message: "Future terminal detail.", + code: .unknown(rawValue: "futureErrorCode"), + additionalDetails: "future additional detail" + ) + ) + ) + ) + } + + @Test func terminalMapperRejectsTurnMismatchForEveryOutcome() throws { + let expectedAttempt = try makeReviewAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-expected", + activeTurnThreadID: "thread-1" + ) + let response = CodexResponse(turnID: "turn-actual") + let outcomes: [CodexTurnOutcome] = [ + .completed(response), + .interrupted(response), + CodexAppServerTestTurnOutcome.failed( + response: response, + error: .init(message: "failed") + ), + .invalidTerminalStatus( + rawStatus: "future", + error: .init(message: "future"), + response: response + ), + ] + + for outcome in outcomes { + #expect( + AppServerReviewTerminalMapper.observed( + outcome, + expectedAttempt: expectedAttempt + ) == .failed(.protocolViolation( + message: "Review terminal turn does not match its attempt." + )) + ) + } } @Test func backendIgnoresAgentMessageDeltasInLifecycleStream() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread-1") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let firstAttempt = try await backend.startReview(makeReviewStart(runID: "run-1", sessionID: "session-1")) try await runtime.transport.enqueueThreadStart(threadID: "thread-2", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-2", reviewThreadID: "review-thread-2") + try await runtime.transport.enqueueReviewStart(turnID: "turn-2", reviewThreadID: "thread-2") let secondAttempt = try await backend.startReview(makeReviewStart(runID: "run-2", sessionID: "session-2")) - try await runtime.transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TestDeltaNotification( - threadID: "review-thread-1", - turnID: "turn-1", - itemID: "msg-1", - delta: "first" - ) + try await runtime.notificationEmitter.emitItemStarted( + threadID: "thread-1", + turnID: "turn-1", + item: .agentMessage(id: "msg-1", text: "") ) - try await runtime.transport.emitServerNotification( - method: "item/agentMessage/delta", - params: TestDeltaNotification( - threadID: "review-thread-2", - turnID: "turn-2", - itemID: "msg-1", - delta: "second" - ) + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: "msg-1", + delta: "first" ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread-1", - turn: .init( - id: "turn-1", - status: "completed", - items: [ - .exitedReviewMode(id: "review-output-1", review: "first") - ] - ) - ) + try await runtime.notificationEmitter.emitItemStarted( + threadID: "thread-2", + turnID: "turn-2", + item: .agentMessage(id: "msg-1", text: "") ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread-2", - turn: .init( - id: "turn-2", - status: "completed", - items: [ - .exitedReviewMode(id: "review-output-2", review: "second") - ] - ) - ) + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: "thread-2", + turnID: "turn-2", + itemID: "msg-1", + delta: "second" + ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + items: [.exitedReviewMode(id: "review-output-1", review: "first")] + ) + try await emitTurn( + on: runtime, + threadID: "thread-2", + turnID: "turn-2", + state: .completed, + items: [.exitedReviewMode(id: "review-output-2", review: "second")] ) - var firstIterator = eventSequence(firstAttempt).makeAsyncIterator() #expect( - try await firstIterator.next() - == .started(turnID: "turn-1", reviewThreadID: "review-thread-1", model: "gpt-5")) - #expect(try await firstIterator.next() == .completed(finalReview: "first")) + try await firstAttempt.observeTerminal() + == .completed(.init( + turnID: makeTurnID("turn-1"), + expectedOutput: try NonEmptyReviewOutput(validating: "first") + )) + ) - var secondIterator = eventSequence(secondAttempt).makeAsyncIterator() #expect( - try await secondIterator.next() - == .started(turnID: "turn-2", reviewThreadID: "review-thread-2", model: "gpt-5")) - #expect(try await secondIterator.next() == .completed(finalReview: "second")) + try await secondAttempt.observeTerminal() + == .completed(.init( + turnID: makeTurnID("turn-2"), + expectedOutput: try NonEmptyReviewOutput(validating: "second") + )) + ) } @Test func backendKeepsCommandOutputDeltasInCodexChat() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - #expect( - try await nextEvent(from: attempt.events) - == .started( - turnID: "turn-1", - reviewThreadID: "review-thread", - model: "gpt-5" - )) - try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", - params: TestDeltaNotification( - threadID: "review-thread", - turnID: "turn-1", - itemID: "cmd-1", - delta: "first" + try await runtime.notificationEmitter.emitItemStarted( + threadID: "thread-1", + turnID: "turn-1", + item: .commandExecution( + id: "cmd-1", + command: "swift test", + cwd: URL(fileURLWithPath: "/tmp/project", isDirectory: true), + status: .inProgress, + aggregatedOutput: "" ) ) - try await runtime.transport.emitServerNotification( - method: "item/commandExecution/outputDelta", - params: TestDeltaNotification( - threadID: "review-thread", - turnID: "turn-1", - itemID: "cmd-1", - delta: "second" - ) + try await runtime.notificationEmitter.emitCommandExecutionOutputDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: "cmd-1", + delta: "first" ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init( - id: "turn-1", - status: "completed", - items: [ - .exitedReviewMode(id: "review-output", review: "No issues found.") - ] - ) - ) + try await runtime.notificationEmitter.emitCommandExecutionOutputDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: "cmd-1", + delta: "second" + ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + items: [.exitedReviewMode(id: "review-output", review: "No issues found.")] ) - #expect(try await nextEvent(from: attempt.events) == .completed(finalReview: "No issues found.")) + #expect( + try await attempt.observeTerminal() + == .completed(.init( + turnID: makeTurnID("turn-1"), + expectedOutput: try NonEmptyReviewOutput(validating: "No issues found.") + )) + ) } - @Test func cleanupDefersReviewThreadDeletionUntilShutdown() async throws { - let runtime = try await CodexAppServerTestRuntime.start() - try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "review-thread") - try await runtime.transport.enqueueEmpty(for: "thread/delete") - try await runtime.transport.enqueueEmpty(for: "thread/delete") - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + @Test func cleanupReleasesLiveSessionAndRetentionOwnerDeletesThread() async throws { + let threadStore = try CodexAppServerTestThreadStore( + plannedStarts: [makeStoredThread(id: "thread-1")] + ) + let runtime = try await CodexAppServerTestRuntime.start(threadStore: threadStore) + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - await backend.cleanupReview(attempt.run) + await backend.cleanupReview(attempt.attempt) - // Completed review chats stay readable until runtime teardown. - #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) + // Attempt finalization releases only the live SDK session. The CRK + // retention owner decides when the persisted chat is retired. + #expect(await runtime.transport.recordedRequests(for: .threadDelete).isEmpty) await backend.cleanupActiveReviewsForShutdown( - .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: []) + .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingAttempts: []) ) + #expect(await runtime.transport.recordedRequests(for: .threadDelete).isEmpty) - let deleteRequests = await runtime.transport.recordedRequests(method: "thread/delete") - #expect(deleteRequests.count == 2) - let deletedIDs = try deleteRequests.map { try jsonObject(from: $0.params)["threadId"] as? String } - #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"]) + let cleanup = await backend.cleanupRetainedReviews( + [attempt.attempt], + additionalThreadIDs: [] + ) + + let deleteRequests = await runtime.transport.recordedRequests(for: .threadDelete) + #expect(cleanup.succeeded) + #expect(deleteRequests.count == 1) + let deletedIDs = deleteRequests.compactMap { request -> CodexThreadID? in + guard case .threadDelete(let id) = request.request else { return nil } + return id + } + #expect(deletedIDs == ["thread-1"]) } - @Test func shutdownCleanupDeletesRecoveryWaitingRunsWithoutInterrupt() async throws { + @Test func connectionTerminationCleanupDoesNotStartRemoteWork() async throws { let runtime = try await CodexAppServerTestRuntime.start() - try await runtime.transport.enqueueEmpty(for: "thread/delete") - try await runtime.transport.enqueueEmpty(for: "thread/delete") - let backend = AppServerCodexReviewBackend(appServer: runtime.server) - let run = CodexReviewBackendModel.Review.Run( + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + let request = CodexReviewRuntimeStopReviewCleanupRequest( + reason: .init(message: "Connection terminated."), + recoveryWaitingAttempts: [] + ) + let methodsBeforeCleanup = await runtime.transport.recordedRequests().map(\.request.operation) + + await backend.cleanupActiveReviewsAfterConnectionTermination(request) + await backend.cleanupReview(attempt.attempt) + await backend.cleanupActiveReviewsAfterConnectionTermination(request) + await backend.cleanupActiveReviewsForShutdown(request) + + #expect(await runtime.transport.recordedRequests().map(\.request.operation) == methodsBeforeCleanup) + } + + @Test func shutdownCleanupReleasesRecoveryWaitingRunsWithoutDeletingThreads() async throws { + let runtime = try await CodexAppServerTestRuntime.start( + threads: [ + makeStoredThread(id: "thread-1"), + makeStoredThread(id: "review-thread"), + ] + ) + let backend = await makeBackend(appServer: runtime.server) + let attempt = try makeReviewAttempt( attemptID: "attempt-recovery", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread", + activeTurnThreadID: "review-thread", model: "gpt-5" ) let request = CodexReviewRuntimeStopReviewCleanupRequest( reason: .init(message: "Review runtime stopped."), - recoveryWaitingRuns: [run] + recoveryWaitingAttempts: [attempt] ) await backend.cleanupActiveReviewsForShutdown(request) let requests = await runtime.transport.recordedRequests() - #expect(requests.map(\.method).contains("turn/interrupt") == false) - let deleteRequests = requests.filter { $0.method == "thread/delete" } + #expect(requests.map(\.request.operation).contains(.turnInterrupt) == false) + #expect(requests.contains { $0.request.operation == .threadDelete } == false) + + let cleanup = await backend.cleanupRetainedReviews( + [attempt], + additionalThreadIDs: [] + ) + let deleteRequests = await runtime.transport.recordedRequests(for: .threadDelete) + #expect(cleanup.succeeded) #expect(deleteRequests.count == 2) - let deletedIDs = try deleteRequests.map { try jsonObject(from: $0.params)["threadId"] as? String } - #expect(Set(deletedIDs.compactMap { $0 }) == ["review-thread", "thread-1"]) + let deletedIDs = deleteRequests.compactMap { request -> CodexThreadID? in + guard case .threadDelete(let id) = request.request else { return nil } + return id + } + #expect(Set(deletedIDs) == ["review-thread", "thread-1"]) } - @Test func interruptReviewCancelsReconstructedRunThroughResumedThread() async throws { + @Test func interruptReviewRejectsAttemptWithoutRegisteredSDKSession() async throws { let runtime = try await CodexAppServerTestRuntime.start() - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" - ) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") - let backend = AppServerCodexReviewBackend(appServer: runtime.server) - let run = CodexReviewBackendModel.Review.Run( + let backend = await makeBackend(appServer: runtime.server) + let attempt = try makeReviewAttempt( attemptID: "attempt-1", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "thread-1", + activeTurnThreadID: "thread-1", model: "gpt-5" ) - try await backend.interruptReview(run, reason: .init(message: "Stop")) + do { + try await backend.interruptReview(attempt, reason: .init(message: "Stop")) + Issue.record("Expected an unregistered attempt to fail without remote work.") + } catch let failure as ReviewBackendFailure { + #expect( + failure == .protocolViolation( + message: "Interrupt requires the active SDK review session for its attempt." + ) + ) + } + + let requests = await runtime.transport.recordedRequests() + #expect(requests.map(\.request.operation) == [.initialize]) + } + + @Test func interruptReviewUsesRegisteredSDKSessionWithoutResume() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-1", + reviewThreadID: "thread-1" + ) + try await runtime.transport.handleTurnInterrupt { _ in } + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + + try await backend.interruptReview(attempt.attempt, reason: .init(message: "Stop")) let requests = await runtime.transport.recordedRequests() - #expect(requests.map(\.method) == ["initialize", "thread/resume", "turn/interrupt"]) - let resume = try #require(requests.first { $0.method == "thread/resume" }) - let resumeParams = try jsonObject(from: resume.params) - #expect(resumeParams["threadId"] as? String == "thread-1") - #expect(resumeParams["model"] as? String == "gpt-5") - let interrupt = try #require(requests.first { $0.method == "turn/interrupt" }) - let interruptParams = try jsonObject(from: interrupt.params) - #expect(interruptParams["threadId"] as? String == "thread-1") - #expect(interruptParams["turnId"] as? String == "turn-1") - } - - @Test func interruptReviewCancelsDetachedReconstructedRunThroughReviewThread() async throws { + #expect(requests.map(\.request.operation) == [ + .initialize, + .threadStart, + .reviewStart, + .turnInterrupt, + ]) + #expect(requests.contains { $0.request.operation == .threadResume } == false) + let interrupt = try #require(requests.compactMap { request + -> (CodexThreadID, CodexTurnID)? in + guard case .turnInterrupt(let threadID, let turnID) = request.request else { + return nil + } + return (threadID, turnID) + }.first) + #expect(interrupt.0 == "thread-1") + #expect(interrupt.1 == "turn-1") + } + + @Test func startReviewMapsRequestFailureToTypedOperation() async throws { let runtime = try await CodexAppServerTestRuntime.start() - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueFailure( + .response(code: -32_000, message: "start unavailable"), + for: .threadStart ) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") - let backend = AppServerCodexReviewBackend(appServer: runtime.server) - let run = CodexReviewBackendModel.Review.Run( + let backend = await makeBackend(appServer: runtime.server) + + do { + _ = try await backend.startReview(makeReviewStart()) + Issue.record("Expected startReview to preserve its typed operation failure.") + } catch { + try expectServerRequestFailure( + error, + operation: .startReview, + method: "thread/start", + code: -32_000 + ) + } + } + + @Test func interruptReviewMapsRequestFailureToTypedOperation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-1", + reviewThreadID: "thread-1" + ) + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + try await runtime.transport.enqueueFailure( + .response(code: -32_011, message: "interrupt unavailable"), + for: .turnInterrupt + ) + + do { + try await backend.interruptReview( + attempt.attempt, + reason: .init(message: "Stop") + ) + Issue.record("Expected interruptReview to preserve its typed operation failure.") + } catch { + try expectServerRequestFailure( + error, + operation: .interruptReview, + method: "turn/interrupt", + code: -32_011 + ) + } + #expect(await runtime.transport.recordedRequests(for: .threadResume).isEmpty) + } + + @Test func prepareRestartMapsRequestFailureToTypedOperation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-1", + reviewThreadID: "thread-1" + ) + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + try await runtime.transport.enqueueFailure( + .response(code: -32_002, message: "resume unavailable"), + for: .threadResume + ) + + do { + _ = try await backend.prepareReviewRestart(attempt.attempt) + Issue.record("Expected prepareRestart to preserve its typed operation failure.") + } catch { + try expectServerRequestFailure( + error, + operation: .prepareRestart, + method: "thread/resume", + code: -32_002 + ) + } + } + + @Test func restartReviewMapsUnavailableTokenToTypedOperation() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + let backend = await makeBackend(appServer: runtime.server) + let interruptedAttempt = try makeReviewAttempt( attemptID: "attempt-1", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread", + activeTurnThreadID: "thread-1", model: "gpt-5" ) + let token = CodexReviewBackendModel.Review.RestartToken( + id: "missing-token", + interruptedAttempt: interruptedAttempt + ) - try await backend.interruptReview(run, reason: .init(message: "Stop")) - - let requests = await runtime.transport.recordedRequests() - #expect(requests.map(\.method) == ["initialize", "thread/resume", "turn/interrupt"]) - let resume = try #require(requests.first { $0.method == "thread/resume" }) - let resumeParams = try jsonObject(from: resume.params) - #expect(resumeParams["threadId"] as? String == "review-thread") - #expect(resumeParams["model"] as? String == "gpt-5") - let interrupt = try #require(requests.first { $0.method == "turn/interrupt" }) - let interruptParams = try jsonObject(from: interrupt.params) - #expect(interruptParams["threadId"] as? String == "review-thread") - #expect(interruptParams["turnId"] as? String == "turn-1") + do { + _ = try await backend.restartPreparedReview(token, request: makeReviewStart()) + Issue.record("Expected restartReview to preserve its typed operation failure.") + } catch let failure as ReviewBackendFailure { + let operationFailure = try #require(failure.operationFailure) + #expect(operationFailure.operation == .restartReview) + #expect(operationFailure.reason == .reviewRestartUnavailable) + } } @Test func preparedReviewRestartCancelsRollsBackAndRestartsOnSameThread() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") - try await runtime.transport.enqueueReviewStart(turnID: "turn-old", reviewThreadID: "review-thread") + try await runtime.transport.enqueueReviewStart(turnID: "turn-old", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") ) - await runtime.transport.enqueueFailure( - code: -32602, - message: "expected active turn id turn-old but found turn-new", - for: "turn/interrupt" + try await runtime.transport.enqueueFailure( + .response( + code: -32602, + message: "expected active turn id turn-old but found turn-new" + ), + for: .turnInterrupt ) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.handleTurnInterrupt { _ in } + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") ) - try await runtime.transport.enqueueEmpty(for: "thread/rollback") - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueThreadRollback( + makeStoredThread(id: "thread-1") ) - try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "review-thread") + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") + ) + try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "thread-1") let prepareTask = Task { - try await backend.prepareReviewRestart(attempt.run) + try await backend.prepareReviewRestart(attempt.attempt) } defer { prepareTask.cancel() } - await runtime.transport.waitForRequest(method: "turn/interrupt", count: 2) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-new", status: "interrupted") - ) + await runtime.transport.waitForRequest(.turnInterrupt, count: 2) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-new", + state: .interrupted ) let token = try await withTimeout { try await prepareTask.value } let restartedAttempt = try await backend.restartPreparedReview(token, request: makeReviewStart()) - #expect(token.interruptedRun == attempt.run) - #expect(restartedAttempt.run.threadID == "thread-1") - #expect(restartedAttempt.run.turnID == "turn-restarted") - #expect(restartedAttempt.run.reviewThreadID == "review-thread") + #expect(token.interruptedAttempt == attempt.attempt) + #expect(restartedAttempt.attempt.threadIdentity.sourceThreadID.rawValue == "thread-1") + #expect(restartedAttempt.attempt.turnID.rawValue == "turn-restarted") + #expect(restartedAttempt.attempt.threadIdentity.activeTurnThreadID.rawValue == "thread-1") let requests = await runtime.transport.recordedRequests() #expect( - requests.map(\.method) == [ - "initialize", - "thread/start", - "review/start", - "thread/resume", - "turn/interrupt", - "turn/interrupt", - "thread/resume", - "thread/rollback", - "thread/resume", - "review/start", + requests.map(\.request.operation) == [ + .initialize, + .threadStart, + .reviewStart, + .threadResume, + .turnInterrupt, + .turnInterrupt, + .threadResume, + .threadRollback, + .threadResume, + .reviewStart, ]) - let resumeThreadIDs = try requests.filter { $0.method == "thread/resume" }.map { - try jsonObject(from: $0.params)["threadId"] as? String - } - #expect(resumeThreadIDs == ["review-thread", "review-thread", "thread-1"]) - let resumeModels = try requests.filter { $0.method == "thread/resume" }.map { - try jsonObject(from: $0.params)["model"] as? String + let resumeRequests = requests.compactMap { request -> (CodexThreadID, CodexThread.Options)? in + guard case .threadResume(let id, let options) = request.request else { return nil } + return (id, options) } + let resumeThreadIDs = resumeRequests.map(\.0.rawValue) + #expect(resumeThreadIDs == ["thread-1", "thread-1", "thread-1"]) + let resumeModels = resumeRequests.map(\.1.model) #expect(resumeModels == ["gpt-5", "gpt-5", "gpt-5"]) // Restarted reviews keep the review thread profile instead of default // Codex settings. - let resumeApprovalPolicies = try requests.filter { $0.method == "thread/resume" }.map { - try jsonObject(from: $0.params)["approvalPolicy"] as? String - } - #expect(resumeApprovalPolicies.last == "never") - let interruptTurnIDs = try requests.filter { $0.method == "turn/interrupt" }.map { - try jsonObject(from: $0.params)["turnId"] as? String + let resumeApprovalModes = resumeRequests.map(\.1.approvalMode) + #expect(resumeApprovalModes.last == .denyAll) + let interruptTurnIDs = requests.compactMap { request -> CodexTurnID? in + guard case .turnInterrupt(_, let turnID) = request.request else { return nil } + return turnID } #expect(interruptTurnIDs == ["turn-old", "turn-new"]) - let rollback = try #require(requests.first { $0.method == "thread/rollback" }) - let rollbackParams = try jsonObject(from: rollback.params) - #expect(rollbackParams["threadId"] as? String == "review-thread") - #expect(rollbackParams["numTurns"] as? Int == 1) - let reviewStarts = requests.filter { $0.method == "review/start" } - let restart = try #require(reviewStarts.last) - let restartParams = try jsonObject(from: restart.params) - #expect(restartParams["threadId"] as? String == "thread-1") + let rollback = try #require(requests.compactMap { request + -> (CodexThreadID, Int)? in + guard case .threadRollback(let id, let numberOfTurns) = request.request else { + return nil + } + return (id, numberOfTurns) + }.first) + #expect(rollback.0 == "thread-1") + #expect(rollback.1 == 1) + let reviewStarts = requests.compactMap { request -> CodexThreadID? in + guard case .reviewStart(let threadID, _, _) = request.request else { return nil } + return threadID + } + #expect(reviewStarts.last == "thread-1") + } + + @Test func discardPreparedRestartTransfersRetentionWithoutDeletingThreads() async throws { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart( + turnID: "turn-old", + reviewThreadID: "thread-1" + ) + await runtime.transport.waitForNotificationStreamCount(1) + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + try await runtime.transport.enqueueThreadResume(makeStoredThread(id: "thread-1")) + try await runtime.transport.handleTurnInterrupt { _ in } + + let prepare = Task { + try await backend.prepareReviewRestart(attempt.attempt) + } + defer { + prepare.cancel() + } + await runtime.transport.waitForRequest(.turnInterrupt) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-old", + state: .interrupted + ) + let token = try await prepare.value + + let retained = await backend.discardPreparedReviewRestart(token) + + #expect(retained == [attempt.attempt]) + #expect(await runtime.transport.recordedRequests(for: .threadDelete).isEmpty) } @Test func shutdownCleanupDoesNotDeleteProvisionalRestartSourceThread() async throws { @@ -576,45 +943,42 @@ struct AppServerClientTests { try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") try await runtime.transport.enqueueReviewStart(turnID: "turn-old", reviewThreadID: "thread-1") await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) + let backend = await makeBackend(appServer: runtime.server) let attempt = try await backend.startReview(makeReviewStart()) - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") ) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.handleTurnInterrupt { _ in } let prepareTask = Task { - try await backend.prepareReviewRestart(attempt.run) + try await backend.prepareReviewRestart(attempt.attempt) } defer { prepareTask.cancel() } - await runtime.transport.waitForRequest(method: "turn/interrupt") - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "thread-1", - turn: .init(id: "turn-old", status: "interrupted") - ) + await runtime.transport.waitForRequest(.turnInterrupt) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-old", + state: .interrupted ) let token = try await withTimeout { try await prepareTask.value } let reviewStartGate = CodexAppServerTestGate() - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") + ) + try await runtime.transport.enqueueThreadRollback( + makeStoredThread(id: "thread-1") ) - try await runtime.transport.enqueueEmpty(for: "thread/rollback") - try await runtime.transport.enqueueJSON( - #"{"thread":{"id":"thread-1"},"model":"gpt-5"}"#, - for: "thread/resume" + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") ) try await runtime.transport.enqueueReviewStart(turnID: "turn-restarted", reviewThreadID: "thread-1") - try await runtime.transport.enqueueEmpty(for: "thread/delete") await runtime.transport.holdNextIgnoringCancellation( - method: "review/start", + .reviewStart, gate: reviewStartGate ) let restartTask = Task { @@ -623,28 +987,126 @@ struct AppServerClientTests { defer { restartTask.cancel() } - await runtime.transport.waitForRequest(method: "review/start", count: 2) + await runtime.transport.waitForRequest(.reviewStart, count: 2) await backend.cleanupActiveReviewsForShutdown( .init( reason: .init(message: "Review runtime stopped."), - recoveryWaitingRuns: [attempt.run] + recoveryWaitingAttempts: [attempt.attempt] )) - #expect(await runtime.transport.recordedRequests(method: "thread/delete").isEmpty) + #expect(await runtime.transport.recordedRequests(for: .threadDelete).isEmpty) await reviewStartGate.open() let restartedAttempt = try await withTimeout { try await restartTask.value } - #expect(restartedAttempt.run.threadID == "thread-1") - #expect(restartedAttempt.run.turnID == "turn-restarted") + #expect(restartedAttempt.attempt.threadIdentity.sourceThreadID.rawValue == "thread-1") + #expect(restartedAttempt.attempt.turnID.rawValue == "turn-restarted") } } +@MainActor +private func makeBackend(appServer: CodexAppServer) -> AppServerCodexReviewBackend { + let modelContainer = CodexModelContainer(appServer: appServer) + return AppServerCodexReviewBackend(modelContainer: modelContainer) +} + +private struct CompletedReviewFixture: Sendable { + let runtime: CodexAppServerTestRuntime + let attempt: BackendReviewAttempt + let observed: ReviewBackendObservedTerminal +} + +private func makeCompletedReviewFixture( + output: String +) async throws -> CompletedReviewFixture { + let runtime = try await CodexAppServerTestRuntime.start() + try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await runtime.transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = await makeBackend(appServer: runtime.server) + let attempt = try await backend.startReview(makeReviewStart()) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + items: [.exitedReviewMode(id: "review-output", review: output)] + ) + return CompletedReviewFixture( + runtime: runtime, + attempt: attempt, + observed: try await attempt.observeTerminal() + ) +} + +enum ReviewPublicationFailureScenario: CaseIterable, Sendable { + case turnUnavailable + case outputMissing + case outputEmpty + case outputMismatch + case refreshFailure +} + +private func makeReviewAttempt( + attemptID: String, + sourceThreadID: String, + turnID: String, + activeTurnThreadID: String, + model: String? = nil +) throws -> ReviewAttempt { + try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: sourceThreadID, + activeTurnThreadID: activeTurnThreadID, + turnID: turnID, + model: model + ) +} + +private func makeTurnID(_ rawValue: String) throws -> ReviewTurnID { + try ReviewTurnID(validating: rawValue) +} + +private func makeThreadID(_ rawValue: String) throws -> ReviewThreadID { + try ReviewThreadID(validating: rawValue) +} + private enum AppServerClientTestTimeout: Error { case timedOut } +private extension ReviewBackendFailure { + var operationFailure: ReviewBackendOperationFailure? { + guard case .operation(let failure) = self else { + return nil + } + return failure + } +} + +private func expectServerRequestFailure( + _ error: any Error, + operation: ReviewBackendOperationFailure.Operation, + method: String, + code: Int +) throws { + let backendFailure = try #require(error as? ReviewBackendFailure) + let operationFailure = try #require(backendFailure.operationFailure) + #expect(operationFailure.operation == operation) + guard case .request( + requestID: _, + method: let actualMethod, + kind: .server(code: let actualCode, turnFailure: let turnFailure) + ) = operationFailure.reason else { + Issue.record("Expected a typed server request failure, got \(operationFailure.reason).") + return + } + #expect(actualMethod == method) + #expect(actualCode == code) + #expect(turnFailure == nil) +} + private func withTimeout( timeout: Duration = .seconds(1), operation: @escaping @Sendable () async throws -> T @@ -663,168 +1125,150 @@ private func withTimeout( } } -private func nextEvent( - from mailbox: BackendReviewEventMailbox, - timeout: Duration = .seconds(1) -) async throws -> CodexReviewBackendModel.Review.Event? { - try await withThrowingTaskGroup(of: CodexReviewBackendModel.Review.Event?.self) { group in - group.addTask { - try await mailbox.next() - } - group.addTask { - try await Task.sleep(for: timeout) - throw AppServerClientTestTimeout.timedOut - } - let event = try await group.next() - group.cancelAll() - return event ?? nil - } -} - -private func eventSequence( - _ attempt: BackendReviewAttempt -) -> BackendReviewEventSequence { - BackendReviewEventSequence(mailbox: attempt.events) -} - -private func makeReviewStart( - runID: String = "run-1", - sessionID: String = "session-1", - target: CodexReviewAPI.Target = .uncommittedChanges -) -> CodexReviewBackendModel.Review.Start { - .init( - runID: runID, - sessionID: sessionID, - request: .init(cwd: "/tmp/project", target: target), - model: "gpt-5" +private func enqueueReviewProjection( + transport: CodexAppServerTestTransport, + threadID: String, + turnID: String?, + output: String? +) async throws { + let items = try output.map { + [try CodexAppServerTestItem.exitedReviewMode(id: "review-output", review: $0)] + } ?? [] + let turns = try turnID.map { + [try CodexAppServerTestTurn( + snapshot: .init( + id: .init(rawValue: $0), + state: .completed, + items: items.map(\.domainProjection) + ), + items: items + )] + } ?? [] + try await transport.enqueueThreadRead( + makeStoredThread( + id: .init(rawValue: threadID), + turns: turns + ) ) } -private func jsonObject(from data: Data) throws -> [String: Any] { - try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) -} - -private struct TestTurnNotification: Encodable, Sendable { - var threadID: String - var turn: TestTurn - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turn - } -} - -private struct TestTurn: Encodable, Sendable { - var id: String - var status: String - var items: [TestItem]? - - init(id: String, status: String, items: [TestItem]? = nil) { - self.id = id - self.status = status - self.items = items - } -} - -private struct TestDeltaNotification: Encodable, Sendable { - var threadID: String - var turnID: String - var itemID: String - var delta: String - var phase: String? - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case itemID = "itemId" - case delta - case phase +private extension CodexAppServerTestTransport { + func enqueueThreadStart( + threadID: String, + model: String? = nil + ) throws { + try enqueueThreadStart( + makeStoredThread( + id: .init(rawValue: threadID), + model: model ?? "gpt-5" + ) + ) } -} - -private struct TestDeltaWithoutTurnIDNotification: Encodable, Sendable { - var threadID: String - var itemID: String - var delta: String - var phase: String? - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case itemID = "itemId" - case delta - case phase + func enqueueReviewStart( + turnID: String, + reviewThreadID: String + ) throws { + try enqueueReviewStart( + makeTestTurn(id: .init(rawValue: turnID), state: .inProgress), + reviewThreadID: .init(rawValue: reviewThreadID) + ) } } -private struct TestAgentMessageNotification: Encodable, Sendable { - var message: String - var phase: String? = nil +private func makeTestTurn( + id: CodexTurnID, + state: CodexTurnSnapshot.State = .completed, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexAppServerTestItem] = [] +) throws -> CodexAppServerTestTurn { + try .init( + snapshot: .init( + id: id, + state: state, + itemsLoadState: itemsLoadState, + items: items.map(\.domainProjection) + ), + items: items + ) } -private struct TestItemNotification: Encodable, Sendable { - var threadID: String - var turnID: String - var item: TestItem - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case item - } +private func emitTurn( + on runtime: CodexAppServerTestRuntime, + threadID: CodexThreadID, + turnID: CodexTurnID, + state: CodexTurnSnapshot.State, + itemsLoadState: CodexTurnItemsLoadState = .full, + items: [CodexAppServerTestItem] = [] +) async throws { + try await runtime.notificationEmitter.emitTurnCompleted( + threadID: threadID, + turn: makeTestTurn( + id: turnID, + state: state, + itemsLoadState: itemsLoadState, + items: items + ) + ) } -private struct TestItem: Encodable, Sendable { - var type: String - var id: String - var text: String? - var phase: String? - var review: String? - var command: String? - var cwd: String? - var aggregatedOutput: String? - var exitCode: Int? - var status: String? - - init( - type: String, - id: String, - text: String? = nil, - phase: String? = nil, - review: String? = nil, - command: String? = nil, - cwd: String? = nil, - aggregatedOutput: String? = nil, - exitCode: Int? = nil, - status: String? = nil - ) { - self.type = type - self.id = id - self.text = text - self.phase = phase - self.review = review - self.command = command - self.cwd = cwd - self.aggregatedOutput = aggregatedOutput - self.exitCode = exitCode - self.status = status - } - - static func finalAnswer(id: String, text: String) -> TestItem { - .init( - type: "agentMessage", +private func makeStoredThread( + id: CodexThreadID, + model: String = "gpt-5", + turns: [CodexAppServerTestTurn] = [], + isArchived: Bool = false +) throws -> CodexAppServerTestStoredThread { + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + return try .init( + snapshot: .init( id: id, - text: text, - phase: "final_answer", - status: "completed" - ) - } + workspace: workspace, + preview: id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20), + status: .idle, + ephemeral: false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: .appServer + ), + runtimeMetadata: .init( + model: model, + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: isArchived + ) +} - static func exitedReviewMode(id: String, review: String) -> TestItem { - .init( - type: "exitedReviewMode", - id: id, - review: review, - status: "completed" +private func makeReviewStart( + runID: String = "run-1", + sessionID: String = "session-1", + target: CodexReviewAPI.Target = .uncommittedChanges +) -> CodexReviewBackendModel.Review.Start { + do { + return .init( + runID: try ReviewRunID(validating: runID), + sessionID: sessionID, + request: .init(cwd: "/tmp/project", target: target), + model: "gpt-5" ) + } catch { + preconditionFailure("Invalid explicit review run fixture: \(error)") } } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 4eaeafa2..ad9e9af3 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1,12 +1,11 @@ import Foundation import AppKit -import AuthenticationServices import Testing import CodexAppServerKit import CodexAppServerKitTesting import CodexReviewKit import CodexReviewAppServer -import CodexReviewHost +@testable import CodexReviewHost import CodexReviewMCPServer import CodexReviewTesting @@ -17,33 +16,51 @@ private extension CodexReviewStore { static func makeLiveStoreForTesting( environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory, - externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = { _ in }, + externalURLOpener: @escaping ExternalURLOpener = { _ in }, + deadlineClock: CodexAppServerTestDeadlineClock? = nil, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? = nil, + accountRegistryLoadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil, + finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? = nil, + finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil, + reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + appServerCloser: @escaping CodexReviewAppServerCloser = { await $0.close() }, transport: FakeCodexAppServerTransport ) -> CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, + authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationCancellationDidRequest: authenticationCancellationDidRequest, + authenticationProductCommitDidApply: authenticationProductCommitDidApply, + authenticationHandleDidBind: authenticationHandleDidBind, + accountRegistryLoadDidBegin: accountRegistryLoadDidBegin, + finalRuntimeRetirementDidClaim: finalRuntimeRetirementDidClaim, + finalShutdownDidRequest: finalShutdownDidRequest, + reconciliationDebtDidClear: reconciliationDebtDidClear, + registryDestinationDidReplace: registryDestinationDidReplace, + appServerCloser: appServerCloser, appServerFactory: { codexHomeURL in try await CodexAppServerTestRuntime.start( transport: transport, - codexHome: codexHomeURL.path + configuration: .init(localProcess: .init( + codexHomeURL: codexHomeURL + )), + deadlineClock: deadlineClock ).server } ) @@ -53,9 +70,8 @@ private extension CodexReviewStore { static func makeLiveStoreForTesting( environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, - nativeAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? = nil, - webAuthenticationSessionFactory: @escaping CodexReviewNativeAuthentication.WebSessionFactory, - externalURLOpener: @escaping @MainActor @Sendable (URL) -> Void = { _ in }, + externalURLOpener: @escaping ExternalURLOpener = { _ in }, + deadlineClock: CodexAppServerTestDeadlineClock? = nil, mcpHTTPServerFactory: (@MainActor @Sendable ( CodexReviewStore, CodexReviewMCPHTTPServer.Configuration, @@ -63,30 +79,49 @@ private extension CodexReviewStore { ) -> any CodexReviewMCPHTTPServing)? = nil, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, + authenticationMutationDidBegin: CodexReviewAuthenticationMutationDidBegin? = nil, + authenticationCancellationDidRequest: CodexReviewAuthenticationCancellationDidRequest? = nil, + authenticationProductCommitDidApply: CodexReviewAuthenticationProductCommitDidApply? = nil, + authenticationHandleDidBind: CodexReviewAuthenticationHandleDidBind? = nil, + accountRegistryLoadDidBegin: CodexReviewAccountRegistryLoadDidBegin? = nil, + finalRuntimeRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? = nil, + finalShutdownDidRequest: CodexReviewFinalShutdownDidRequest? = nil, + reconciliationDebtDidClear: CodexReviewReconciliationDebtDidClear? = nil, + registryDestinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil, + appServerCloser: @escaping CodexReviewAppServerCloser = { await $0.close() }, transportFactory: @escaping @MainActor @Sendable (URL) async throws -> FakeCodexAppServerTransport ) -> CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, + authenticationMutationDidBegin: authenticationMutationDidBegin, + authenticationCancellationDidRequest: authenticationCancellationDidRequest, + authenticationProductCommitDidApply: authenticationProductCommitDidApply, + authenticationHandleDidBind: authenticationHandleDidBind, + accountRegistryLoadDidBegin: accountRegistryLoadDidBegin, + finalRuntimeRetirementDidClaim: finalRuntimeRetirementDidClaim, + finalShutdownDidRequest: finalShutdownDidRequest, + reconciliationDebtDidClear: reconciliationDebtDidClear, + registryDestinationDidReplace: registryDestinationDidReplace, + appServerCloser: appServerCloser, appServerFactory: { codexHomeURL in let transport = try await transportFactory(codexHomeURL) return try await CodexAppServerTestRuntime.start( transport: transport, - codexHome: codexHomeURL.path + configuration: .init(localProcess: .init( + codexHomeURL: codexHomeURL + )), + deadlineClock: deadlineClock ).server } ) @@ -96,65 +131,28 @@ private extension CodexReviewStore { @Suite("host composition") @MainActor struct CodexReviewHostTests { - @Test func hostStartsAndStopsRuntimeWithFakeBackend() async { - let backend = FakeCodexReviewBackend() - let host = CodexReviewHost( - backend: backend, - endpoint: URL(string: "http://localhost:9417/mcp") - ) - - await host.start() - #expect(host.store.serverState == .running) - #expect(host.store.serverURL == URL(string: "http://localhost:9417/mcp")) - - await host.stop() - #expect(host.store.serverState == .stopped) - } - - @Test func hostStartLoadsSettingsBeforeStandaloneReviews() async throws { - let backend = FakeCodexReviewBackend(settings: .init(model: "gpt-5.5")) - let host = CodexReviewHost(backend: backend) - - await host.start() - let reviewTask = Task { @MainActor in - try await host.store.startReview( - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) - ) + @Test func stoppedPrimaryReconciliationWaitsOnlyForItsReservedTransition() async throws { + let coordinator = AccountRuntimeTransitionCoordinator() + var admittedLogin: AccountRuntimeTransitionCoordinator.LoginAdmission? + var didAdmitLogin = false + coordinator.installDidBecomeIdle { + guard didAdmitLogin == false else { return } + didAdmitLogin = true + admittedLogin = try! coordinator.reserveLoginAdmission() + } + let reconciliationCompleted = CompletionFlag() + let reconciliation = Task { @MainActor in + await coordinator.performStoppedPrimaryReconciliation { _ in } + await reconciliationCompleted.complete() } - await backend.waitForStartReview() - - let commands = await backend.recordedCommands() - #expect(commands.first == .readSettings) - let startReview = try #require(commands.compactMap { command -> CodexReviewBackendModel.Review.Start? in - if case .startReview(let request) = command { - request - } else { - nil - } - }.first) - #expect(startReview.model == "gpt-5.5") - - await backend.yield(.completed(finalReview: "No issues found.")) - await backend.finishEvents() - _ = try await reviewTask.value - } - - @Test func hostStartPreservesBackendAccountID() async { - let backend = FakeCodexReviewBackend(auth: .init( - accounts: [ - .init(id: .init("review@example.com"), label: "review@example.com", isActive: true), - ], - activeAccountID: .init("review@example.com") - )) - let host = CodexReviewHost(backend: backend) - await host.start() - await host.store.refreshAuthentication() + try #require(await waitUntil(timeout: .seconds(2)) { + await reconciliationCompleted.isCompleted() + }) + #expect(coordinator.hasActiveLoginTransition) - #expect(host.store.auth.selectedAccount?.accountKey == "review@example.com") - #expect(host.store.auth.selectedAccount?.email == "review@example.com") - #expect(host.store.auth.persistedActiveAccountKey == "review@example.com") + coordinator.finishLoginAdmission(try #require(admittedLogin)) + await reconciliation.value } @Test func runtimePreferencesNormalizeInvalidValues() { @@ -255,17 +253,12 @@ struct CodexReviewHostTests { let homeURL = try temporaryHome() let configuredCodexHomeURL = homeURL.appendingPathComponent("custom-codex-home", isDirectory: true) let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], runtimePreferences: .init(codexHomePath: configuredCodexHomeURL.path), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == configuredCodexHomeURL) return transport @@ -281,17 +274,12 @@ struct CodexReviewHostTests { @Test func liveStorePublishesPrimaryAppServerLifecycle() async throws { let homeURL = try temporaryHome() let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) var observedLifecycleStates: [Bool] = [] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { _, configuration, _ in NoopMCPHTTPServer(endpoint: configuration.url()) }, @@ -312,16 +300,395 @@ struct CodexReviewHostTests { #expect(observedLifecycleStates == [true, false]) } - @Test func liveStorePassesRuntimePreferenceMCPPortAndPathToHTTPServerFactory() async throws { + @Test func liveStoreDoesNotPublishLifecycleWhenMCPStagingFails() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + let mcpServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stageFailure: .stagingFailed + ) + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, _, _ in mcpServer }, + mcpHTTPServerBindChecker: { _ in }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transportFactory: { _ in transport } + ) + + await store.start(forceRestartIfNeeded: true) + + guard case .failed = store.serverState else { + Issue.record("Expected failed server state.") + return + } + #expect(store.serverURL == nil) + #expect(observedLifecycleStates.isEmpty) + #expect(await mcpServer.snapshot() == .init(stageCount: 1, activateCount: 0, stopCount: 1)) + } + + @Test func liveStoreStopWinsOverLateMCPStagingCompletion() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + let stageGate = CodexAppServerTestGate() + let mcpServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stageGate: stageGate + ) + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, _, _ in mcpServer }, + mcpHTTPServerBindChecker: { _ in }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transportFactory: { _ in transport } + ) + + let start = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) + } + await stageGate.waitUntilBlocked() + await store.stop() + + #expect(store.serverState == .stopped) + #expect(store.serverURL == nil) + #expect(observedLifecycleStates.isEmpty) + #expect(await mcpServer.snapshot() == .init(stageCount: 1, activateCount: 0, stopCount: 1)) + + await stageGate.open() + await start.value + + #expect(store.serverState == .stopped) + #expect(store.serverURL == nil) + #expect(observedLifecycleStates.isEmpty) + #expect(await mcpServer.snapshot() == .init(stageCount: 1, activateCount: 0, stopCount: 1)) + } + + @Test func liveStoreJoinsConcurrentStopsIntoOneRuntimeTeardown() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let stopGate = CodexAppServerTestGate() + let secondStopStarted = CodexAppServerTestGate() + let mcpServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stopGate: stopGate + ) + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, _, _ in mcpServer }, + mcpHTTPServerBindChecker: { _ in }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transportFactory: { _ in transport } + ) + await store.start(forceRestartIfNeeded: true) + + let firstStop = Task { @MainActor in + await store.stop() + } + await stopGate.waitUntilBlocked() + let secondStopStartedWaiter = Task { + await secondStopStarted.waitIgnoringCancellation() + } + await secondStopStarted.waitUntilBlocked() + let secondStop = Task { @MainActor in + await secondStopStarted.open() + await store.stop() + } + await secondStopStartedWaiter.value + await stopGate.open() + await firstStop.value + await secondStop.value + + #expect(await mcpServer.snapshot().stopCount == 1) + #expect(observedLifecycleStates == [true, false]) + } + + @Test func liveStoreIgnoresLateStagingGenerationCompletion() async throws { + let homeURL = try temporaryHome() + let firstFactoryGate = CodexAppServerTestGate() + let firstTransport = FakeCodexAppServerTransport() + let secondTransport = FakeCodexAppServerTransport() + try await secondTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + for _ in 0..<2 { + try await secondTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await secondTransport.enqueueModels(.init(models: [])) + } + var factoryCallCount = 0 + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, configuration, _ in + NoopMCPHTTPServer(endpoint: configuration.url()) + }, + mcpHTTPServerBindChecker: { _ in }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transportFactory: { _ in + factoryCallCount += 1 + if factoryCallCount == 1 { + await firstFactoryGate.waitIgnoringCancellation() + return firstTransport + } + return secondTransport + } + ) + + let oldStart = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) + } + await firstFactoryGate.waitUntilBlocked() + await store.stop() + await store.start(forceRestartIfNeeded: true) + #expect(store.serverState == .running) + #expect(observedLifecycleStates == [true]) + + await firstFactoryGate.open() + await oldStart.value + + #expect(store.serverState == .running) + #expect(store.serverURL != nil) + #expect(observedLifecycleStates == [true]) + await store.stop() + #expect(observedLifecycleStates == [true, false]) + } + + @Test func liveStoreFinalStopRetiresRunsWhileReplacementIsStaging() async throws { + let homeURL = try temporaryHome() + let firstTransport = FakeCodexAppServerTransport() + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await firstTransport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + + let secondTransport = FakeCodexAppServerTransport() + try await secondTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await secondTransport.enqueueSuccess(for: .threadDelete) + var transports = [firstTransport, secondTransport] + let replacementStageGate = CodexAppServerTestGate() + let replacementMCPServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stageGate: replacementStageGate + ) + var mcpFactoryCallCount = 0 + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, configuration, _ in + mcpFactoryCallCount += 1 + if mcpFactoryCallCount == 1 { + return NoopMCPHTTPServer(endpoint: configuration.url()) + } + return replacementMCPServer + }, + mcpHTTPServerBindChecker: { _ in }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + let review = Task { @MainActor in + try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" + }) + let reviewOutput = try CodexAppServerTestItem.exitedReviewMode( + id: "review-output", + review: "No issues found." + ) + let completedTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-1", + state: .completed, + items: [reviewOutput.domainProjection] + ), + items: [reviewOutput] + ) + try await firstTransport.enqueueThreadRead( + makeHostStoredThread(id: "thread-1", turns: [completedTurn]) + ) + try await firstTransport.notificationEmitter.emitTurnCompleted( + threadID: "thread-1", + turn: completedTurn + ) + #expect(try await review.value.presentation.status == .succeeded) + + let restart = Task { @MainActor in + await store.restart() + } + await replacementStageGate.waitUntilBlocked() + #expect(store.reviewRuns.count == 1) + + await store.stop() + + #expect(store.serverState == .stopped) + #expect(store.reviewRuns.isEmpty) + #expect(await secondTransport.recordedRequests(for: .threadDelete).count == 1) + #expect(observedLifecycleStates == [true, false]) + #expect(await replacementMCPServer.snapshot() == .init( + stageCount: 1, + activateCount: 0, + stopCount: 1 + )) + + await replacementStageGate.open() + await restart.value + #expect(store.serverState == .stopped) + #expect(observedLifecycleStates == [true, false]) + } + + @Test func liveStoreFinalStopRetiresRunsWhenRequestedDuringPreservingAppServerClose() async throws { + let homeURL = try temporaryHome() + let firstTransport = FakeCodexAppServerTransport() + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueThreadStart(threadID: "thread-close-race", model: "gpt-5") + try await firstTransport.enqueueReviewStart( + turnID: "turn-close-race", + reviewThreadID: "thread-close-race" + ) + let cleanupTransport = FakeCodexAppServerTransport() + try await cleanupTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await cleanupTransport.enqueueSuccess(for: .threadDelete) + var transports = [firstTransport, cleanupTransport] + let firstCloseStarted = OneShotSignal() + let firstCloseGate = CodexAppServerTestGate() + let finalRetirementClaimed = OneShotSignal() + var closeCount = 0 + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + finalRuntimeRetirementDidClaim: { + await finalRetirementClaimed.signal() + }, + appServerCloser: { appServer in + closeCount += 1 + if closeCount == 1 { + await firstCloseStarted.signal() + await firstCloseGate.waitIgnoringCancellation() + } + await appServer.close() + }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + let review = Task { @MainActor in + try await store.startReview( + sessionID: "session-close-race", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-close-race" + }) + let reviewOutput = try CodexAppServerTestItem.exitedReviewMode( + id: "review-output-close-race", + review: "No issues found." + ) + let completedTurn = try CodexAppServerTestTurn( + snapshot: .init( + id: "turn-close-race", + state: .completed, + items: [reviewOutput.domainProjection] + ), + items: [reviewOutput] + ) + try await firstTransport.enqueueThreadRead( + makeHostStoredThread(id: "thread-close-race", turns: [completedTurn]) + ) + try await firstTransport.notificationEmitter.emitTurnCompleted( + threadID: "thread-close-race", + turn: completedTurn + ) + #expect(try await review.value.presentation.status == .succeeded) + + await firstTransport.failConnection(.closed) + await firstCloseStarted.wait() + #expect(store.reviewRuns.count == 1) + #expect(observedLifecycleStates == [true, false]) + + let finalStop = Task { @MainActor in + await store.stop() + } + await finalRetirementClaimed.wait() + await firstCloseGate.open() + await finalStop.value + + #expect(store.serverState == .stopped) + #expect(store.reviewRuns.isEmpty) + #expect(closeCount == 2) + #expect(await cleanupTransport.recordedRequests(for: .threadDelete).count == 1) + #expect(observedLifecycleStates == [true, false]) + #expect(store.auth.selectedAccount == nil) + } + + @Test func liveStoreWaitUntilStoppedJoinsFinalCoordinatorBeforeRuntimeStopStarts() async throws { let homeURL = try temporaryHome() let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let finalRequestGate = CodexAppServerTestGate() + let waiterStarted = OneShotSignal() + let waiterCompleted = OneShotSignal() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + finalShutdownDidRequest: { + await finalRequestGate.waitIgnoringCancellation() + }, + transport: transport ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + + await store.start(forceRestartIfNeeded: true) + let finalStop = Task { @MainActor in + await store.stop() + } + await finalRequestGate.waitUntilBlocked() + let waiter = Task { @MainActor in + await waiterStarted.signal() + await store.waitUntilStopped() + await waiterCompleted.signal() + } + await waiterStarted.wait() + #expect(await waiterCompleted.snapshot() == false) + + await finalRequestGate.open() + await finalStop.value + await waiter.value + #expect(await waiterCompleted.snapshot()) + #expect(store.serverState == .stopped) + } + + @Test func liveStorePassesRuntimePreferenceMCPPortAndPathToHTTPServerFactory() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) var capturedConfiguration: CodexReviewMCPHTTPServer.Configuration? var capturedLogProjectionProvider: ReviewMCPLogProjectionProvider? let store = CodexReviewStore.makeLiveStoreForTesting( @@ -330,7 +697,6 @@ struct CodexReviewHostTests { mcpPort: 54321, mcpPath: "custom-mcp" ), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { store, configuration, logProjectionProvider in capturedConfiguration = configuration capturedLogProjectionProvider = logProjectionProvider @@ -357,15 +723,17 @@ struct CodexReviewHostTests { await store.stop() } - @Test func liveStoreReportsMCPPortOwnerWhenEndpointPortInUseAndDoesNotLaunchAppServer() async throws { + @Test func liveStoreReportsMCPPortOwnerAfterStagingAppServer() async throws { let homeURL = try temporaryHome() let port = 54321 + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) var didLaunchAppServer = false + var observedLifecycleStates: [Bool] = [] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], runtimePreferences: .init(mcpHost: "127.0.0.1", mcpPort: port), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { _, configuration, _ in NoopMCPHTTPServer(endpoint: configuration.url()) }, @@ -382,15 +750,19 @@ struct CodexReviewHostTests { port: configuration.port ) }, + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, transportFactory: { _ in didLaunchAppServer = true - return FakeCodexAppServerTransport() + return transport } ) await store.start(forceRestartIfNeeded: true) - #expect(didLaunchAppServer == false) + #expect(didLaunchAppServer) + #expect(observedLifecycleStates.isEmpty) guard case .failed(let message) = store.serverState else { Issue.record("Expected failed server state.") return @@ -400,15 +772,16 @@ struct CodexReviewHostTests { #expect(message.contains("Quit that process or change the MCP port in Settings")) } - @Test func liveStoreReportsMCPPortInUseWhenOwnerCannotBeResolved() async throws { + @Test func liveStoreReportsMCPPortInUseWithoutOwnerAfterStagingAppServer() async throws { let homeURL = try temporaryHome() let port = 54322 + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) var didLaunchAppServer = false let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], runtimePreferences: .init(mcpHost: "127.0.0.1", mcpPort: port), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { _, configuration, _ in NoopMCPHTTPServer(endpoint: configuration.url()) }, @@ -421,13 +794,13 @@ struct CodexReviewHostTests { }, transportFactory: { _ in didLaunchAppServer = true - return FakeCodexAppServerTransport() + return transport } ) await store.start(forceRestartIfNeeded: true) - #expect(didLaunchAppServer == false) + #expect(didLaunchAppServer) guard case .failed(let message) = store.serverState else { Issue.record("Expected failed server state.") return @@ -458,7 +831,6 @@ struct CodexReviewHostTests { ) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: FakeCodexAppServerTransport() ) @@ -475,751 +847,3631 @@ struct CodexReviewHostTests { #expect(providerAccount.capabilities.supportsRateLimitRefresh == false) } - @Test func liveStoreSkipsRateLimitRefreshForUnsupportedActiveAccount() async throws { - let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue( - TestAccountReadResponse(account: .init(type: "apiKey")), - for: "account/read" - ) - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + @Test func liveStoreBuildsSwitchPlanFromDiskWhenAuthModelIsStale() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com", "second@example.com"] ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try writeSavedAccountAuth(homeURL: homeURL, accountKey: "first@example.com") + try writeSavedAccountAuth(homeURL: homeURL, accountKey: "second@example.com") let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transport: transport + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() ) + let staleSecondAccount = CodexReviewAccount(email: "second@example.com") + store.auth.applyPersistedAccountStates( + [savedAccountPayload(from: staleSecondAccount)], + activeAccountKey: nil + ) + store.auth.selectPersistedAccount(nil) - await store.start(forceRestartIfNeeded: true) - await transport.waitForRequestCount(4) - await store.refreshAccountRateLimits(accountKey: "api-key") - await Task.yield() + try await store.switchAccount(staleSecondAccount) - #expect(store.auth.selectedAccount?.kind == .apiKey) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", + #expect(try activeAccountKey(homeURL: homeURL) == "second@example.com") + #expect(store.auth.persistedAccounts.map(\.accountKey) == [ + "first@example.com", + "second@example.com", ]) + #expect(store.auth.selectedAccount?.accountKey == "second@example.com") } - @Test func liveStoreCompletesNativeLoginFromAuthenticationSessionAndAccountNotifications() async throws { - let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") - ), - for: "account/login/start" - ) - try await transport.enqueue( - AppServerAPI.Account.Login.Complete.Response(), - for: "account/login/complete" - ) - try await transport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "new@example.com", planType: "plus")), - for: "account/read" + @Test func liveStoreChoosesAddAccountRuntimeFromLeasedDiskSnapshot() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] ) - try await transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 20, windowDurationMins: 300) + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - let sessions = FakeWebAuthenticationSessions() - let externalURLOpener = FakeExternalURLOpener() + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + let isolatedTransport = FakeCodexAppServerTransport() + try await isolatedTransport.enqueueChatGPTLogin( + loginID: "isolated-login", + authenticationURL: testAuthenticationURL + ) + try await isolatedTransport.enqueueChatGPTLoginCancellation(.canceled) let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, - externalURLOpener: externalURLOpener.open, - transport: transport + environment: ["HOME": homeURL.path], + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + return isolatedTransport + } ) await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - await store.addAccount() - await transport.waitForRequestCount(5) - #expect(store.auth.isAuthenticating) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) - let loginRequest = try #require(await transport.recordedRequests().first { - $0.method == "account/login/start" - }) - let loginParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: loginRequest.params) - #expect(loginParams.nativeWebAuthentication == .init( - callbackURLScheme: "lynnpd.CodexReviewMonitor.auth" - )) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) + store.auth.applyPersistedAccountStates([], activeAccountKey: nil) + store.auth.selectPersistedAccount(nil) + try await store.addAccount() + + await isolatedTransport.waitForRequest(.accountLoginStart) + #expect(await mainTransport.recordedRequests(for: .accountLoginStart).isEmpty) + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreRejectsLoginWhoseLeaseReturnsAfterFinalAdmissionCloses() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let accountKey = "active@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: accountKey, + accounts: [accountKey] + ) + let firstMainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: firstMainTransport, email: accountKey) + let secondMainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: secondMainTransport, email: accountKey) + var mainTransports = [firstMainTransport, secondMainTransport] + let isolatedTransport = FakeCodexAppServerTransport() + try await isolatedTransport.enqueueChatGPTLogin( + loginID: "post-final-login", + authenticationURL: testAuthenticationURL + ) + try await isolatedTransport.enqueueChatGPTLoginCancellation(.canceled) + let leasedMutationGate = CodexAppServerTestGate() + let finalShutdownRequested = OneShotSignal() + var isolatedFactoryCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationMutationDidBegin: { + await leasedMutationGate.waitIgnoringCancellation() + }, + finalShutdownDidRequest: { + await finalShutdownRequested.signal() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransports.removeFirst() + } + isolatedFactoryCount += 1 + return isolatedTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + let lateLogin = Task { @MainActor in + try await store.addAccount() + } + await leasedMutationGate.waitUntilBlocked() + + let finalStop = Task { @MainActor in + await store.stop() + } + await finalShutdownRequested.wait() + await leasedMutationGate.open() + await finalStop.value + #expect(isolatedFactoryCount == 0) + #expect(await firstMainTransport.recordedRequests(for: .accountLoginStart).isEmpty) + + do { + try await lateLogin.value + Issue.record("Expected final shutdown to reject the login before session installation.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + #expect(isolatedFactoryCount == 0) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await isolatedTransport.waitForRequest(.accountLoginStart) + #expect(isolatedFactoryCount == 1) + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreClassifiesConcurrentLoginBeforeSessionInstallation() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "pre-session-concurrent-login", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let mutationGate = CodexAppServerTestGate() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + authenticationMutationDidBegin: { + await mutationGate.waitIgnoringCancellation() + }, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + let firstLogin = Task { @MainActor in + try await store.addAccount() + } + await mutationGate.waitUntilBlocked() + + do { + try await store.addAccount() + Issue.record("Expected the reserved pre-session login to reject a concurrent command.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .alreadyInProgress) + } + + await mutationGate.open() + try await firstLogin.value + await transport.waitForRequest(.accountLoginStart) + #expect(await transport.recordedRequests(for: .accountLoginStart).count == 1) + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreCollectsPostCommitAccountDirectoryDebtOnNextLoad() async throws { + let homeURL = try temporaryHome() + let accountKey = "removed@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: nil, + accounts: [accountKey] + ) + try writeSavedAccountAuth(homeURL: homeURL, accountKey: accountKey) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + try await store.removeAccount(accountKey: accountKey) + + let orphanedAccountURL = homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) + let orphanedRevisionURL = orphanedAccountURL + .appendingPathComponent("revisions", isDirectory: true) + .appendingPathComponent("post-commit-orphan.json") + try FileManager.default.createDirectory( + at: orphanedRevisionURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(#"{"tokens":{"id_token":"orphan"}}"#.utf8).write(to: orphanedRevisionURL) + + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + + #expect(FileManager.default.fileExists(atPath: orphanedAccountURL.path) == false) + #expect(try activeAccountKey(homeURL: homeURL) == nil) + } + + @Test func liveStoreRetriesCredentialTemporaryHomeCleanupDebtOnLoad() throws { + let homeURL = try temporaryHome() + let credentialHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: credentialHomeURL, + withIntermediateDirectories: false + ) + try Data(#"{"tokens":{"id_token":"pending-cleanup"}}"#.utf8).write( + to: credentialHomeURL.appendingPathComponent("auth.json") + ) + let debtURL = temporaryHomeCleanupDebtURL(homeURL: homeURL) + try FileManager.default.createDirectory( + at: debtURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try JSONSerialization.data(withJSONObject: ["paths": [credentialHomeURL.path]]) + .write(to: debtURL) + + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + + #expect(FileManager.default.fileExists(atPath: credentialHomeURL.path) == false) + #expect(FileManager.default.fileExists(atPath: debtURL.path) == false) + } + + @Test func accountRegistryKeepsTemporaryHomeDebtUntilParentRemovalIsDurable() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let temporaryParentURL = FileManager.default.temporaryDirectory.standardizedFileURL + let synchronization = PathSynchronizationFailureProbe( + path: temporaryParentURL.path, + failureCount: 0 + ) + let registry = AccountRegistryStore( + codexHomeURL: codexHomeURL, + directoryDurabilityDidSynchronize: synchronization.failIfNeeded + ) + let temporaryHomeURL = try await registry.reserveTemporaryCodexHome( + kind: .authentication + ) + try Data(#"{"tokens":{"id_token":"pending-cleanup"}}"#.utf8).write( + to: temporaryHomeURL.appendingPathComponent("auth.json") + ) + synchronization.arm(failureCount: 2) + + await registry.finishTemporaryCodexHome(temporaryHomeURL) + + #expect(FileManager.default.fileExists(atPath: temporaryHomeURL.path) == false) + let debtURL = temporaryHomeCleanupDebtURL(homeURL: homeURL) + #expect(FileManager.default.fileExists(atPath: debtURL.path)) + let debt = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: debtURL)) as? [String: Any] + ) + #expect((debt["paths"] as? [String])?.contains(temporaryHomeURL.path) == true) + #expect(synchronization.recordedFailureCount == 2) + + await registry.finishTemporaryCodexHome(temporaryHomeURL) + + #expect(FileManager.default.fileExists(atPath: debtURL.path) == false) + #expect(synchronization.recordedInvocationCount == 3) + } + + @Test func accountRegistryRejectsLateRuntimeWriteAfterGenerationAdmissionCloses() async throws { + let homeURL = try temporaryHome() + let accountKey = "active@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: accountKey, + accounts: [accountKey] + ) + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let registry = AccountRegistryStore(codexHomeURL: codexHomeURL) + let snapshot = try await registry.load() + var payload = try #require(snapshot.accounts.first) + payload.rateLimits = [(windowDurationMinutes: 300, usedPercent: 99, resetsAt: nil)] + payload.lastRateLimitFetchAt = Date(timeIntervalSince1970: 123) + + await registry.openRuntimeAdmission(generation: 7) + await registry.closeRuntimeAdmission(generation: 7) + let before = try Data(contentsOf: accountRegistryURL(homeURL: homeURL)) + do { + try await registry.updateCachedRateLimits( + from: payload, + runtimeAuthorization: .init(generation: 7) + ) + Issue.record("Expected a closed runtime generation to reject its late disk commit.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + + #expect(try Data(contentsOf: accountRegistryURL(homeURL: homeURL)) == before) + } + + @Test func liveStoreFailsFastForCorruptAccountRegistry() async throws { + let homeURL = try temporaryHome() + let registryURL = homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("registry.json") + try FileManager.default.createDirectory( + at: registryURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("not-json".utf8).write(to: registryURL) + var didLaunchAppServer = false + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in + didLaunchAppServer = true + return FakeCodexAppServerTransport() + } + ) + + await store.start(forceRestartIfNeeded: true) + + #expect(didLaunchAppServer == false) + #expect(store.auth.errorMessage?.contains("account registry is inconsistent") == true) + guard case .failed(let message) = store.serverState else { + Issue.record("Expected corrupt persistence to fail the runtime start.") + return + } + #expect(message.contains("account registry is inconsistent")) + } + + @Test func liveStoreMigratesLegacyRegistryToVersionedImmutableRevision() throws { + let homeURL = try temporaryHome() + let accountKey = "legacy@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: accountKey, + accounts: [accountKey] + ) + try writeSavedAccountAuth(homeURL: homeURL, accountKey: accountKey) + + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + + let registry = try accountRegistryObject(homeURL: homeURL) + #expect(registry["schemaVersion"] as? Int == 1) + #expect((registry["generation"] as? Int) == 1) + #expect((registry["contentHash"] as? String)?.isEmpty == false) + let records = try #require(registry["accounts"] as? [[String: Any]]) + let record = try #require(records.first) + let revision = try #require(record["immutableRevision"] as? String) + let revisionURL = homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) + .appendingPathComponent("revisions", isDirectory: true) + .appendingPathComponent("\(revision).json") + #expect(try Data(contentsOf: revisionURL) == Data("{\"tokens\":{\"id_token\":\"legacy@example.com\"}}".utf8)) + let orphanURL = revisionURL.deletingLastPathComponent().appendingPathComponent("orphan.json") + try Data(#"{"tokens":{"id_token":"orphan@example.com"}}"#.utf8).write(to: orphanURL) + + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + + #expect(FileManager.default.fileExists(atPath: revisionURL.path)) + #expect(FileManager.default.fileExists(atPath: orphanURL.path) == false) + } + + @Test func liveStoreFailsFastForMissingImmutableAuthRevision() async throws { + let homeURL = try temporaryHome() + let accountKey = "missing-revision@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: accountKey, + accounts: [accountKey] + ) + try writeSavedAccountAuth(homeURL: homeURL, accountKey: accountKey) + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + let registry = try accountRegistryObject(homeURL: homeURL) + let records = try #require(registry["accounts"] as? [[String: Any]]) + let revision = try #require(records.first?["immutableRevision"] as? String) + let revisionURL = homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) + .appendingPathComponent("revisions", isDirectory: true) + .appendingPathComponent("\(revision).json") + try FileManager.default.removeItem(at: revisionURL) + var didLaunchAppServer = false + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in + didLaunchAppServer = true + return FakeCodexAppServerTransport() + } + ) + + await store.start(forceRestartIfNeeded: true) + + #expect(didLaunchAppServer == false) + #expect(store.auth.errorMessage?.contains("account registry is inconsistent") == true) + } + + @Test func liveStoreFailsFastForRegistryContentHashMismatch() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: nil, + accounts: ["stored@example.com"] + ) + _ = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: FakeCodexAppServerTransport() + ) + let registryURL = accountRegistryURL(homeURL: homeURL) + var registry = try accountRegistryObject(homeURL: homeURL) + registry["activeAccountKey"] = "stored@example.com" + try JSONSerialization.data(withJSONObject: registry).write(to: registryURL) + var didLaunchAppServer = false + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in + didLaunchAppServer = true + return FakeCodexAppServerTransport() + } + ) + + await store.start(forceRestartIfNeeded: true) + + #expect(didLaunchAppServer == false) + #expect(store.auth.errorMessage?.contains("content hash") == true) + } + + @Test func liveStoreSkipsRateLimitRefreshForUnsupportedActiveAccount() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .apiKey), + requiresOpenAIAuth: false + ) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + transport: transport + ) + await store.start(forceRestartIfNeeded: true) + await transport.waitForRequestCount(4) + await store.refreshAccountRateLimits(accountKey: "api-key") + await Task.yield() + + #expect(store.auth.selectedAccount?.kind == .apiKey) + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + ]) + } + + @Test func liveStoreCompletesStockLoginAfterAccountReadiness() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) + let newAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + try await store.addAccount() + await transport.waitForRequestCount(5) + #expect(store.auth.isAuthenticating) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + do { + try await store.addAccount() + Issue.record("Expected an active authentication mutation to reject a second command.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .alreadyInProgress) + } + #expect(store.auth.isAuthenticating) + #expect(await transport.recordedRequests(for: .accountLoginStart).count == 1) + do { + try await store.removeAccount(accountKey: "missing@example.com") + Issue.record("Expected account mutation rejection while authentication is active.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + #expect(store.auth.isAuthenticating) + try FileManager.default.createDirectory(at: mainCodexHomeURL, withIntermediateDirectories: true) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "login-1", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + #expect(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.accountKey == "new@example.com" + }) + await transport.waitForRequestCount(9) + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountRead, + .accountRead, + .accountRateLimitsRead, + .accountRead, + ]) + await store.stop() + } + + @Test func liveStoreCancelsLoginWhenOpeningAuthenticationURLFails() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let externalURLOpener = FakeExternalURLOpener( + failure: CodexReviewAPI.Error.io("Authentication presentation failed.") + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + externalURLOpener: externalURLOpener.open, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() await transport.waitForRequestCount(6) - let completeRequest = try #require(await transport.recordedRequests().first { - $0.method == "account/login/complete" + + #expect( + failedMessage(from: store.auth.phase) + == "Failed to open the authentication URL: https://example.com/auth" + ) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountLoginCancel, + ]) + await store.stop() + } + + @Test func liveStoreRejectsForcedRuntimeStartWhileLoginOwnsPrimaryRuntime() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "login-runtime-start-admission", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + var factoryCount = 0 + var closeCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerCloser: { appServer in + closeCount += 1 + await appServer.close() + }, + transportFactory: { _ in + factoryCount += 1 + return transport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + + await store.start(forceRestartIfNeeded: true) + + #expect(store.serverState == .running) + #expect(store.auth.isAuthenticating) + #expect(factoryCount == 1) + #expect(closeCount == 0) + #expect(await transport.recordedRequests(for: .accountLoginCancel).isEmpty) + + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreLetsCancellationWinBeforeURLPresentationClaim() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "login-presentation-claim", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let presentationClaimGate = CodexAppServerTestGate() + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + authenticationHandleDidBind: { + await presentationClaimGate.waitIgnoringCancellation() + }, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + let login = Task { @MainActor in + try await store.addAccount() + } + await presentationClaimGate.waitUntilBlocked() + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await transport.waitForRequest(.accountLoginCancel) + await presentationClaimGate.open() + try await login.value + await cancellation.value + + #expect(externalURLOpener.openedURLs.isEmpty) + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(store.auth.isAuthenticating == false) + try await store.reorderPersistedAccount(accountKey: "missing@example.com", toIndex: 0) + await store.stop() + } + + @Test func liveStoreJoinsConcurrentCancellationAndStopInOneLoginTermination() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let cancelGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + .accountLoginCancel, + gate: cancelGate + ) + var appServerLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + appServerLifecycleHandler: { container in + appServerLifecycleStates.append(container != nil) + }, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + + async let cancel: Void = store.cancelAuthentication() + await transport.waitForRequest(.accountLoginCancel) + async let stop: Void = store.stop() + try #require(await waitUntil(timeout: .seconds(2)) { + appServerLifecycleStates == [true, false] + }) + + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + await cancelGate.open() + await cancel + await stop + + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(store.auth.isAuthenticating == false) + #expect(store.serverURL == nil) + } + + @Test func liveStoreDefersSupersededPrimaryLoginHandoffToRuntimeStop() async throws { + let homeURL = try temporaryHome() + 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-primary-handoff", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + let reconciliationTransport = FakeCodexAppServerTransport() + try await reconciliationTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await reconciliationTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await reconciliationTransport.enqueueModels(.init(models: [])) + let cancellationGate = CodexAppServerTestGate() + let finalShutdownRequested = OneShotSignal() + var transports = [initialTransport, reconciliationTransport] + var appServerCloseCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + finalShutdownDidRequest: { await finalShutdownRequested.signal() }, + appServerCloser: { appServer in + appServerCloseCount += 1 + await appServer.close() + }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + await initialTransport.holdNextIgnoringCancellation( + .accountLoginCancel, + gate: cancellationGate + ) + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationGate.waitUntilBlocked() + let stop = Task { @MainActor in + await store.stop() + } + await finalShutdownRequested.wait() + await cancellationGate.open() + await cancellation.value + await stop.value + await store.waitUntilStopped() + + #expect(transports.isEmpty) + #expect(appServerCloseCount == 2) + #expect(store.serverState == .stopped) + #expect(store.auth.isAuthenticating == false) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + } + + @Test func liveStoreRoutesExplicitClosingHandoffOnceWhenRuntimeFails() async throws { + let homeURL = try temporaryHome() + 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: "explicit-closing-runtime-failure", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + let cancelGate = CodexAppServerTestGate() + await initialTransport.holdNextIgnoringCancellation( + .accountLoginCancel, + gate: cancelGate + ) + let replacementTransport = FakeCodexAppServerTransport() + try await replacementTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + var transports = [initialTransport, replacementTransport] + var lifecycleStates: [Bool] = [] + let firstRuntimeClosed = OneShotSignal() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerLifecycleHandler: { container in + lifecycleStates.append(container != nil) + if container == nil { + Task { await firstRuntimeClosed.signal() } + } + }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await initialTransport.waitForRequest(.accountLoginCancel) + await cancelGate.waitUntilBlocked() + + await initialTransport.failConnection(.closed) + await firstRuntimeClosed.wait() + await cancelGate.open() + await cancellation.value + try #require(await waitUntil(timeout: .seconds(2)) { + store.serverState == .running + }) + + #expect(await initialTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(transports.isEmpty) + #expect(lifecycleStates == [true, false, true]) + await store.stop() + } + + @Test func liveStoreRetainsPrimaryAuthenticationHandoffAcrossIncompleteStopRetry() async throws { + let homeURL = try temporaryHome() + 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: "stop-incomplete-primary-handoff", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + let replacementTransport = FakeCodexAppServerTransport() + try await replacementTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + try await replacementTransport.enqueueSuccess(for: .threadDelete) + try await replacementTransport.enqueueSuccess(for: .threadDelete) + var transports = [initialTransport, replacementTransport] + var factoryCount = 0 + var closeCount = 0 + let liveStore = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerCloser: { appServer in + closeCount += 1 + await appServer.close() + }, + transportFactory: { _ in + factoryCount += 1 + return transports.removeFirst() + } + ) + let journal = ControlledHostRetentionJournal() + let store = CodexReviewStore.makeTestingStore( + backend: liveStore.backend, + reviewThreadRetentionJournal: journal + ) + + await store.start(forceRestartIfNeeded: true) + let retainedRunID = try ReviewRunID(validating: "retained-run") + let retainedAttempt = makeReviewAttemptForTesting( + attemptID: "retained-attempt", + sourceThreadID: "retained-source", + activeTurnThreadID: "retained-review", + turnID: "retained-turn" + ) + try await store.reviewThreadRetentionRegistry.claim( + retainedAttempt, + for: retainedRunID, + scope: store.currentReviewThreadRetentionScope + ) + await journal.failReplacements("injected retention write failure") + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + + await store.stop() + + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(factoryCount == 1) + #expect(closeCount == 0) + #expect(await initialTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(await store.reviewThreadRetentionRegistry.acceptance().isAccepting == false) + let lateCancellationCompleted = CompletionFlag() + let lateCancellationStarted = OneShotSignal() + let lateCancellation = Task { @MainActor in + await lateCancellationStarted.signal() + await store.cancelAuthentication() + await lateCancellationCompleted.complete() + } + await lateCancellationStarted.wait() + #expect(await lateCancellationCompleted.isCompleted() == false) + + await journal.failReplacements(nil) + await store.stop() + await lateCancellation.value + + #expect(store.serverState == .stopped) + #expect(factoryCount == 2) + #expect(closeCount == 2) + #expect(await lateCancellationCompleted.isCompleted()) + #expect(await initialTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(try await journal.load().entries.isEmpty) + #expect(await store.reviewThreadRetentionRegistry.acceptance().isAccepting) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + #expect(transports.isEmpty) + _ = liveStore + } + + @Test func liveStoreKeepsEmptyRegistryWhenUnknownPrimaryCancellationIsSignedOut() async throws { + try await exerciseUnknownPrimaryCancellation( + previousAccountKey: nil, + observedAccountKey: nil, + expectedActiveAccountKey: nil + ) + } + + @Test func liveStoreDeactivatesPreviousAccountWhenUnknownPrimaryCancellationIsSignedOut() async throws { + try await exerciseUnknownPrimaryCancellation( + previousAccountKey: "previous@example.com", + observedAccountKey: nil, + expectedActiveAccountKey: nil + ) + } + + @Test func liveStoreKeepsPreviousAccountWhenUnknownPrimaryCancellationObservesSameAccount() async throws { + try await exerciseUnknownPrimaryCancellation( + previousAccountKey: "previous@example.com", + observedAccountKey: "previous@example.com", + expectedActiveAccountKey: "previous@example.com" + ) + } + + @Test func liveStoreCommitsNewAccountWhenUnknownPrimaryCancellationObservesNewAccount() async throws { + try await exerciseUnknownPrimaryCancellation( + previousAccountKey: "previous@example.com", + observedAccountKey: "new@example.com", + expectedActiveAccountKey: "new@example.com" + ) + } + + @Test func liveStoreJoinsLateCancellationToActivePrimaryReconciliationResult() async throws { + let homeURL = try temporaryHome() + 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: "unknown-primary-cancel-join", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + let replacementTransport = FakeCodexAppServerTransport() + try await replacementTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + let replacementStageGate = CodexAppServerTestGate() + let replacementMCPServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stageGate: replacementStageGate + ) + var transports = [initialTransport, replacementTransport] + var mcpFactoryCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, configuration, _ in + mcpFactoryCount += 1 + return mcpFactoryCount == 1 + ? NoopMCPHTTPServer(endpoint: configuration.url()) + : replacementMCPServer + }, + mcpHTTPServerBindChecker: { _ in }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + let firstCompleted = CompletionFlag() + let firstCancellation = Task { @MainActor in + await store.cancelAuthentication() + await firstCompleted.complete() + } + await replacementStageGate.waitUntilBlocked() + let secondCompleted = CompletionFlag() + let secondCancellationStarted = OneShotSignal() + let secondCancellation = Task { @MainActor in + await secondCancellationStarted.signal() + await store.cancelAuthentication() + await secondCompleted.complete() + } + await secondCancellationStarted.wait() + + #expect(await firstCompleted.isCompleted() == false) + #expect(await secondCompleted.isCompleted() == false) + #expect(await initialTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(mcpFactoryCount == 2) + + await replacementStageGate.open() + await firstCancellation.value + await secondCancellation.value + + #expect(await firstCompleted.isCompleted()) + #expect(await secondCompleted.isCompleted()) + #expect(store.serverState == .running) + #expect(await initialTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(transports.isEmpty) + await store.stop() + } + + @Test func liveStoreKeepsPrimaryReconciliationFailureStickyUntilExplicitRepairCommits() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: nil, + accounts: ["first@example.com", "second@example.com"] + ) + 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: "primary-unknown-cancel", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + let repairTransport = FakeCodexAppServerTransport() + try await repairTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await repairTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await repairTransport.enqueueModels(.init(models: [])) + try await repairTransport.enqueueChatGPTLogin( + loginID: "post-repair-login", + authenticationURL: testAuthenticationURL + ) + try await repairTransport.enqueueChatGPTLoginCancellation(.canceled) + var mainFactoryCallCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { codexHomeURL in + #expect(codexHomeURL == mainCodexHomeURL) + mainFactoryCallCount += 1 + switch mainFactoryCallCount { + case 1: + return initialTransport + case 2: + throw CodexReviewAPI.Error.io("replacement validation runtime unavailable") + case 3: + return repairTransport + default: + Issue.record("Unexpected primary runtime factory invocation.") + return repairTransport + } + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + await store.cancelAuthentication() + try #require(await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { + return true + } + return false + }) + #expect(mainFactoryCallCount == 2) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + + let reviewRunCount = store.reviewRuns.count + do { + _ = try await store.startReview( + sessionID: "sticky-failure-review", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected sticky reconciliation failure to reject review admission.") + } catch {} + #expect(store.reviewRuns.count == reviewRunCount) + do { + try await store.addAccount() + Issue.record("Expected sticky reconciliation failure to reject account admission.") + } catch {} + #expect(await initialTransport.recordedRequests(for: .accountLoginStart).count == 1) + do { + try await store.reorderPersistedAccount( + accountKey: "second@example.com", + toIndex: 0 + ) + Issue.record("Expected sticky reconciliation failure to reject a runtime-independent account mutation.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + + await store.start(forceRestartIfNeeded: true) + + #expect(store.serverState == .running) + #expect(mainFactoryCallCount == 3) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + try await store.reorderPersistedAccount( + accountKey: "second@example.com", + toIndex: 0 + ) + #expect(store.auth.persistedAccounts.map(\.accountKey).first == "second@example.com") + try await store.addAccount() + await repairTransport.waitForRequest(.accountLoginStart) + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreRecordsReconciliationDebtAsFirstAccountArtifact() async throws { + let homeURL = try temporaryHome() + 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: "first-account-artifact-debt", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) + var factoryCallCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in + factoryCallCount += 1 + if factoryCallCount == 1 { + return initialTransport + } + throw CodexReviewAPI.Error.io("replacement runtime unavailable") + } + ) + + await store.start(forceRestartIfNeeded: true) + let accountsURL = accountReconciliationDebtURL(homeURL: homeURL) + .deletingLastPathComponent() + #expect(FileManager.default.fileExists(atPath: accountsURL.path) == false) + try await store.addAccount() + await initialTransport.waitForRequest(.accountLoginStart) + + await store.cancelAuthentication() + + #expect(factoryCallCount == 2) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + #expect(FileManager.default.fileExists(atPath: accountsURL.path)) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + await store.stop() + } + + @Test func accountRegistryRetriesAncestorDurabilityAfterVisibleDirectoryFailure() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL + .appendingPathComponent("missing-parent", isDirectory: true) + .appendingPathComponent("custom-codex-home", isDirectory: true) + let synchronization = DirectorySynchronizationFailureProbe() + let registry = AccountRegistryStore( + codexHomeURL: codexHomeURL, + directoryDurabilityDidSynchronize: synchronization.failFirstSynchronization + ) + + do { + try await registry.recordReconciliationDebt( + expectedAccount: .signedOut, + message: "first attempt" + ) + Issue.record("Expected the first directory durability confirmation to fail.") + } catch {} + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURLForCodexHome(codexHomeURL).path + ) == false) + + try await registry.recordReconciliationDebt( + expectedAccount: .signedOut, + message: "retry" + ) + + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURLForCodexHome(codexHomeURL).path + )) + #expect(synchronization.failureCount == 1) + #expect(synchronization.synchronizedPaths.last == "/") + #expect(synchronization.synchronizedPaths.filter { $0 == "/" }.count == 1) + } + + @Test func liveStoreFailsClosedWhenDirectRegistryMutationDurabilityIsUnresolved() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com", "second@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: transport, + email: "first@example.com" + ) + let corruption = RegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: corruption.corruptIfArmed, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + corruption.arm() + await #expect(throws: CodexReviewAuthenticationFailure.self) { + try await store.reorderPersistedAccount( + accountKey: "second@example.com", + toIndex: 0 + ) + } + + #expect(corruption.corruptionCount == 1) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(store.auth.errorMessage?.contains("unresolved durable outcome") == true) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + let debtData = try Data(contentsOf: accountReconciliationDebtURL(homeURL: homeURL)) + let debt = try #require(JSONSerialization.jsonObject(with: debtData) as? [String: Any]) + #expect(debt["expectation"] as? String == "observedAccount") + #expect(debt["accountKey"] as? String == "first@example.com") + #expect(debt["provider"] as? String == "chatGPT") + do { + try await store.reorderPersistedAccount( + accountKey: "first@example.com", + toIndex: 0 + ) + Issue.record("Expected unresolved registry durability to keep account admission closed.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + await store.stop() + } + + @Test func liveStoreRecordsExactProviderForUnresolvedDirectRegistryMutation() async throws { + let homeURL = try temporaryHome() + try writeRegistryRecords( + homeURL: homeURL, + activeAccountKey: "api-key", + records: [ + [ + "accountKey": "api-key", + "kind": "apiKey", + "email": "API Key", + "planType": "pro", + ], + [ + "accountKey": "second@example.com", + "kind": "chatgpt", + "email": "second@example.com", + "planType": "pro", + ], + ] + ) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .apiKey), + requiresOpenAIAuth: false + ) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let corruption = RegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: corruption.corruptIfArmed, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + corruption.arm() + await #expect(throws: CodexReviewAuthenticationFailure.self) { + try await store.reorderPersistedAccount( + accountKey: "second@example.com", + toIndex: 0 + ) + } + + let debtData = try Data(contentsOf: accountReconciliationDebtURL(homeURL: homeURL)) + let debt = try #require(JSONSerialization.jsonObject(with: debtData) as? [String: Any]) + #expect(debt["expectation"] as? String == "observedAccount") + #expect(debt["accountKey"] as? String == "api-key") + #expect(debt["provider"] as? String == "apiKey") + await store.stop() + } + + @Test func liveStoreUsesCurrentRuntimeRecoveryWhenDirectMutationObservationAdvances() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com", "second@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + let loadGate = ArmableAsyncHookGate() + let corruption = RegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + accountRegistryLoadDidBegin: { await loadGate.waitIfArmed() }, + registryDestinationDidReplace: corruption.corruptIfArmed, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await loadGate.arm() + corruption.arm() + let mutation = Task { @MainActor in + try await store.reorderPersistedAccount( + accountKey: "second@example.com", + toIndex: 0 + ) + } + await loadGate.waitUntilBlocked() + try Data(#"{"tokens":{"id_token":"third@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await loadGate.open() + await #expect(throws: CodexReviewAuthenticationFailure.self) { + try await mutation.value + } + + let debtData = try Data(contentsOf: accountReconciliationDebtURL(homeURL: homeURL)) + let debt = try #require(JSONSerialization.jsonObject(with: debtData) as? [String: Any]) + #expect(debt["expectation"] as? String == "reconcileCurrentRuntime") + #expect({ if case .failed = store.serverState { return true }; return false }()) + await store.stop() + } + + @Test func liveStoreRestoresDebtWhenStagingConsumerFailsAfterDebtClear() async throws { + let homeURL = try temporaryHome() + let expectedAccountKey = "expected@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: expectedAccountKey, + accounts: [expectedAccountKey] + ) + try writeReconciliationDebt( + homeURL: homeURL, + expectedAccountKey: expectedAccountKey + ) + + let interruptedRepair = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: interruptedRepair, + email: expectedAccountKey, + planType: .pro, + usedPercent: 10 + ) + let wrongAccountRepair = FakeCodexAppServerTransport() + try await wrongAccountRepair.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "wrong@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await wrongAccountRepair.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await wrongAccountRepair.enqueueModels(.init(models: [])) + let successfulRepair = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: successfulRepair, + email: expectedAccountKey, + planType: .pro, + usedPercent: 20 + ) + var transports = [interruptedRepair, wrongAccountRepair, successfulRepair] + var injectConsumerFailure = true + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + reconciliationDebtDidClear: { appServer in + guard injectConsumerFailure else { + return + } + injectConsumerFailure = false + await appServer.close() + }, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + + await store.start(forceRestartIfNeeded: true) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + + await store.start(forceRestartIfNeeded: true) + #expect(store.serverState == .running) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + await store.stop() + } + + @Test func liveStoreFailsClosedWhenKnownPrimaryAccountCannotCommitRegistry() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: nil, + accounts: ["first@example.com", "second@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "known-primary-commit-failure", + authenticationURL: testAuthenticationURL + ) + let newAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + let accountReadGate = CodexAppServerTestGate() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + try FileManager.default.createDirectory( + at: mainCodexHomeURL, + withIntermediateDirectories: true + ) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + await transport.holdNextIgnoringCancellation(.accountRead, gate: accountReadGate) + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "known-primary-commit-failure", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await accountReadGate.waitUntilBlocked() + try FileManager.default.removeItem( + at: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + await accountReadGate.open() + + try #require(await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { return true } + return false + }) + #expect(store.auth.errorMessage?.contains("reconciliation remains pending") == true) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + do { + try await store.reorderPersistedAccount(accountKey: "second@example.com", toIndex: 0) + Issue.record("Expected committed primary reconciliation debt to close account admission.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + await store.stop() + } + + @Test func liveStoreCommitsPrimaryAccountAfterPostReplaceSyncFailure() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "post-replace-primary", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + let replaceFailure = RegistryReplaceFailureProbe() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: replaceFailure.failIfArmed, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + try FileManager.default.createDirectory( + at: mainCodexHomeURL, + withIntermediateDirectories: true + ) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + replaceFailure.arm() + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "post-replace-primary", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.selectedAccount?.accountKey == "new@example.com" + }) + #expect(store.serverState == .running) + #expect(replaceFailure.failureCount == 1) + #expect(try activeAccountKey(homeURL: homeURL) == "new@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + #expect(try savedAccountAuth( + homeURL: homeURL, + accountKey: "new@example.com" + ) == Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8)) + await store.stop() + } + + @Test func liveStoreCancelsLoginBeforeIsolatedRuntimeFactoryReturns() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + + let lateRuntimeGate = CodexAppServerTestGate() + let lateTransport = FakeCodexAppServerTransport() + let nextTransport = FakeCodexAppServerTransport() + try await nextTransport.enqueueChatGPTLogin( + loginID: "login-next", + authenticationURL: testAuthenticationURL + ) + try await nextTransport.enqueueChatGPTLoginCancellation(.canceled) + var isolatedFactoryCount = 0 + var lateCodexHomeURL: URL? + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedFactoryCount += 1 + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + if isolatedFactoryCount == 1 { + lateCodexHomeURL = codexHomeURL + await lateRuntimeGate.waitIgnoringCancellation() + return lateTransport + } + return nextTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + let add = Task { @MainActor in + try await store.addAccount() + } + await lateRuntimeGate.waitUntilBlocked() + let reservedTemporaryHome = try #require(lateCodexHomeURL) + let cleanupDebt = try #require( + JSONSerialization.jsonObject( + with: Data(contentsOf: temporaryHomeCleanupDebtURL(homeURL: homeURL)) + ) as? [String: Any] + ) + #expect((cleanupDebt["paths"] as? [String])?.contains(reservedTemporaryHome.path) == true) + async let cancel: Void = store.cancelAuthentication() + await lateRuntimeGate.open() + try await add.value + await cancel + + let resolvedLateCodexHomeURL = try #require(lateCodexHomeURL) + #expect(externalURLOpener.openedURLs.isEmpty) + #expect(await lateTransport.recordedRequests(for: .accountLoginStart).isEmpty) + #expect(FileManager.default.fileExists(atPath: resolvedLateCodexHomeURL.path) == false) + + try await store.addAccount() + await nextTransport.waitForRequest(.accountLoginStart) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + await store.cancelAuthentication() + await store.stop() + } + + @Test func liveStoreKeepsPreBindCancellationWhenIsolatedRuntimeFactoryFailsLate() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: mainTransport, + email: "active@example.com" + ) + let factoryGate = CodexAppServerTestGate() + let cancellationRequested = OneShotSignal() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + await factoryGate.waitIgnoringCancellation() + throw CodexReviewAPI.Error.io("late isolated factory failure") + } + ) + + await store.start(forceRestartIfNeeded: true) + let add = Task { @MainActor in + try await store.addAccount() + } + await factoryGate.waitUntilBlocked() + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + await factoryGate.open() + try await add.value + await cancellation.value + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.errorMessage == nil) + #expect(store.auth.isAuthenticating == false) + await store.stop() + } + + @Test func liveStoreCancelsLoginAfterStartRequestBeforeHandleBinding() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "login-held", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueChatGPTLoginCancellation(.canceled) + let loginStartGate = CodexAppServerTestGate() + await loginTransport.holdNextIgnoringCancellation( + .accountLoginStart, + gate: loginStartGate + ) + var isolatedCodexHomeURL: URL? + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + let add = Task { @MainActor in + try await store.addAccount() + } + await loginTransport.waitForRequest(.accountLoginStart) + await loginStartGate.waitUntilBlocked() + async let cancel: Void = store.cancelAuthentication() + await loginStartGate.open() + try await add.value + await cancel + + let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) + #expect(externalURLOpener.openedURLs.isEmpty) + #expect(await loginTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + await store.stop() + } + + @Test func liveStoreKeepsPreBindCancellationWhenLoginStartFailsLate() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: mainTransport, + email: "active@example.com" + ) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueFailure( + .response(code: -32603, message: "late login-start failure"), + for: .accountLoginStart + ) + let loginStartGate = CodexAppServerTestGate() + await loginTransport.holdNextIgnoringCancellation( + .accountLoginStart, + gate: loginStartGate + ) + let cancellationRequested = OneShotSignal() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + transportFactory: { codexHomeURL in + codexHomeURL == mainCodexHomeURL ? mainTransport : loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + let add = Task { @MainActor in + try await store.addAccount() + } + await loginStartGate.waitUntilBlocked() + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + await loginStartGate.open() + try await add.value + await cancellation.value + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.errorMessage == nil) + #expect(store.auth.isAuthenticating == false) + #expect(await loginTransport.recordedRequests(for: .accountLoginCancel).isEmpty) + await store.stop() + } + + @Test func liveStoreKeepsCancellationWhenIsolatedAccountReadFailsAfterSDKSuccess() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: mainTransport, + email: "active@example.com" + ) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "login-read-failure", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueFailure( + .response(code: -32603, message: "late isolated account read failure"), + for: .accountRead + ) + let accountReadGate = CodexAppServerTestGate() + await loginTransport.holdNextIgnoringCancellation( + .accountRead, + gate: accountReadGate + ) + let cancellationRequested = OneShotSignal() + var isolatedCodexHomeURL: URL? + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "login-read-failure", + completion: .succeeded + ) + try await loginTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await accountReadGate.waitUntilBlocked() + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + await accountReadGate.open() + await cancellation.value + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.persistedAccounts.map(\.accountKey) == ["active@example.com"]) + #expect(store.auth.errorMessage == nil) + #expect(FileManager.default.fileExists(atPath: try #require(isolatedCodexHomeURL).path) == false) + await store.stop() + } + + @Test func liveStoreLetsIsolatedCancellationWinBeforeRegistryProductCommit() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let oldAccountKey = "active@example.com" + let newAccountKey = "new@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: oldAccountKey, + accounts: [oldAccountKey] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: mainTransport, email: oldAccountKey) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "isolated-before-commit", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: newAccountKey, + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await loginTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 25 + )) + let accountReadGate = CodexAppServerTestGate() + await loginTransport.holdNextIgnoringCancellation(.accountRead, gate: accountReadGate) + let cancellationRequested = OneShotSignal() + var isolatedCodexHomeURL: URL? + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: resolvedIsolatedCodexHomeURL.appendingPathComponent("auth.json") + ) + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "isolated-before-commit", + completion: .succeeded + ) + try await loginTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await accountReadGate.waitUntilBlocked() + + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + await accountReadGate.open() + await cancellation.value + + #expect(try activeAccountKey(homeURL: homeURL) == oldAccountKey) + #expect(store.auth.persistedAccounts.map(\.accountKey) == [oldAccountKey]) + #expect(store.auth.selectedAccount?.accountKey == oldAccountKey) + #expect(store.auth.errorMessage == nil) + #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + await store.stop() + } + + @Test func liveStoreKeepsIsolatedSuccessWhenCancellationArrivesAfterRegistryProductCommit() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let oldAccountKey = "active@example.com" + let newAccountKey = "new@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: oldAccountKey, + accounts: [oldAccountKey] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: mainTransport, email: oldAccountKey) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "isolated-after-commit", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: newAccountKey, + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await loginTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 25 + )) + let productCommitApplied = OneShotSignal() + let productCommitReleaseGate = CodexAppServerTestGate() + let cancellationRequested = OneShotSignal() + var isolatedCodexHomeURL: URL? + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + authenticationProductCommitDidApply: { + await productCommitApplied.signal() + await productCommitReleaseGate.waitIgnoringCancellation() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: resolvedIsolatedCodexHomeURL.appendingPathComponent("auth.json") + ) + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "isolated-after-commit", + completion: .succeeded + ) + try await loginTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await productCommitApplied.wait() + + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + await productCommitReleaseGate.open() + await cancellation.value + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.persistedAccounts.contains { $0.accountKey == newAccountKey } + && store.auth.isAuthenticating == false + }) + + #expect(try activeAccountKey(homeURL: homeURL) == oldAccountKey) + #expect(store.auth.persistedAccounts.map(\.accountKey).contains(newAccountKey)) + #expect(store.auth.selectedAccount?.accountKey == oldAccountKey) + #expect(store.auth.errorMessage == nil) + #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + await store.stop() + } + + @Test func liveStoreKeepsPostProductCommitFailureWhenCancellationArrivesDuringFinalLoad() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let oldAccountKey = "active@example.com" + let newAccountKey = "new@example.com" + try writeRegistry( + homeURL: homeURL, + activeAccountKey: oldAccountKey, + accounts: [oldAccountKey] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: mainTransport, email: oldAccountKey) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "isolated-post-commit-failure", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: newAccountKey, + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await loginTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 25 + )) + let productCommitApplied = OneShotSignal() + let productCommitReleaseGate = CodexAppServerTestGate() + let cancellationRequested = OneShotSignal() + var isolatedCodexHomeURL: URL? + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + authenticationCancellationDidRequest: { + await cancellationRequested.signal() + }, + authenticationProductCommitDidApply: { + await productCommitApplied.signal() + await productCommitReleaseGate.waitIgnoringCancellation() + }, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: resolvedIsolatedCodexHomeURL.appendingPathComponent("auth.json") + ) + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "isolated-post-commit-failure", + completion: .succeeded + ) + try await loginTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await productCommitApplied.wait() + + let cancellation = Task { @MainActor in + await store.cancelAuthentication() + } + await cancellationRequested.wait() + try Data("invalid registry".utf8).write( + to: accountRegistryURL(homeURL: homeURL) + ) + await productCommitReleaseGate.open() + await cancellation.value + + #expect(store.auth.selectedAccount?.accountKey == oldAccountKey) + #expect(store.auth.persistedAccounts.map(\.accountKey) == [oldAccountKey]) + #expect(store.auth.errorMessage?.contains("account registry") == true) + #expect(store.auth.isAuthenticating == false) + #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + #expect(await loginTransport.recordedRequests(for: .accountLoginCancel).isEmpty) + await store.stop() + } + + @Test func liveStoreUsesInjectedMonotonicDeadlineForLoginCancellationAcknowledgement() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "login-deadline", + authenticationURL: testAuthenticationURL + ) + let replacementTransport = FakeCodexAppServerTransport() + try await replacementTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + var transports = [transport, replacementTransport] + let deadlineClock = CodexAppServerTestDeadlineClock() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + deadlineClock: deadlineClock, + transportFactory: { _ in transports.removeFirst() } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + let cancel = Task { @MainActor in + await store.cancelAuthentication() + } + await transport.waitForRequest(.accountLoginCancel) + try await deadlineClock.waitForSleeperCount(1) + deadlineClock.advance(by: .seconds(5)) + await cancel.value + + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.isAuthenticating == false && store.serverState == .running + }) + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + await store.stop() + } + + @Test func liveStoreKeepsNewLoginGenerationWhenOldNotificationsArrive() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-old", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + await store.cancelAuthentication() + + try await transport.enqueueChatGPTLogin(loginID: "login-new", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart, count: 2) + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "login-old", + completion: .failed(message: "late old-generation completion") + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await Task.yield() + + #expect(store.auth.isAuthenticating) + do { + try await store.addAccount() + Issue.record("Expected the new login generation to remain active.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .alreadyInProgress) + } + + await store.cancelAuthentication() + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 2) + await store.stop() + } + + @Test func liveStoreInstallsSessionBeforeAnAlreadyCompletedLoginRootRuns() async throws { + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-early", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLoginCancellation(.canceled) + let loginStartGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation( + .accountLoginStart, + gate: loginStartGate + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + let firstLogin = Task { @MainActor in + try await store.addAccount() + } + await transport.waitForRequest(.accountLoginStart) + await loginStartGate.waitUntilBlocked() + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "login-early", + completion: .failed(message: "login completed before handle publication") + ) + await loginStartGate.open() + try await firstLogin.value + + try #require(await waitUntil(timeout: .seconds(2)) { + failedMessage(from: store.auth.phase) == "login completed before handle publication" + }) + #expect( + failedMessage(from: store.auth.phase) + == "login completed before handle publication" + ) + await store.cancelAuthentication() + try await transport.enqueueChatGPTLogin(loginID: "login-next", authenticationURL: testAuthenticationURL) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart, count: 2) + #expect(store.auth.isAuthenticating) + + await store.cancelAuthentication() + #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + await store.stop() + } + + @Test func liveStoreAddsAccountWithoutSwitchingExistingActiveAccount() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try FileManager.default.createDirectory(at: mainCodexHomeURL, withIntermediateDirectories: true) + try Data(#"{"tokens":{"id_token":"active@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + + let authTransport = FakeCodexAppServerTransport() + try await authTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) + try await authTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await authTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 25 + )) + let refreshTransport = FakeCodexAppServerTransport() + let refreshGate = AsyncGate() + await refreshTransport.holdNext(.accountRateLimitsRead, gate: refreshGate) + try await refreshTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await refreshTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 44 + )) + var nonPrimaryTransports = [authTransport, refreshTransport] + var nonPrimaryRuntimeIndex = 0 + var refreshCodexHomeURL: URL? + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + let runtimeIndex = nonPrimaryRuntimeIndex + nonPrimaryRuntimeIndex += 1 + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + if runtimeIndex == 0 { + try Data("{\"tokens\":{\"id_token\":\"login-token\"}}".utf8) + .write(to: codexHomeURL.appendingPathComponent("auth.json")) + } else { + refreshCodexHomeURL = codexHomeURL + } + return nonPrimaryTransports.removeFirst() + } + ) + + await store.start(forceRestartIfNeeded: true) + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + + try await store.addAccount() + await authTransport.waitForNotificationStreamCount(1) + await authTransport.waitForRequestCount(2) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + let loginRequest = try #require( + await authTransport.recordedRequests(for: .accountLoginStart).first + ) + #expect(loginRequest.request == .accountLoginStart) + try await authTransport.notificationEmitter.emitLoginCompleted( + loginID: "login-2", + completion: .succeeded + ) + try await authTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + #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 + && store.auth.isAuthenticating == false + }) + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.persistedActiveAccountKey == "active@example.com") + #expect(store.auth.persistedAccounts.map(\.accountKey).contains("new@example.com")) + #expect(store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25) + #expect(await mainTransport.recordedRequests().map(\.request.operation).contains(.accountLoginStart) == false) + #expect(await authTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountLoginStart, + .accountRead, + .accountRateLimitsRead, + ]) + try await store.reorderPersistedAccount(accountKey: "new@example.com", toIndex: 1) + #expect(store.auth.persistedAccounts.map(\.accountKey) == [ + "active@example.com", + "new@example.com", + ]) + + async let refresh: Void = store.refreshAccountRateLimits(accountKey: "new@example.com") + await refreshTransport.waitForRequestCount(3) + let capturedRefreshCodexHomeURL = try #require(refreshCodexHomeURL) + try Data("{\"tokens\":{\"id_token\":\"refreshed-token\"}}".utf8) + .write(to: capturedRefreshCodexHomeURL.appendingPathComponent("auth.json")) + await refreshGate.open() + await refresh + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 44 + }) + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 44) + #expect(try savedAccountAuth(homeURL: homeURL, accountKey: "new@example.com") == Data("{\"tokens\":{\"id_token\":\"refreshed-token\"}}".utf8)) + #expect(await refreshTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .accountRateLimitsRead, + ]) + } + + @Test func liveStoreDoesNotApplySavedAccountRateLimitsFromDifferentAuth() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com", "new@example.com"] + ) + try writeSavedAccountAuth(homeURL: homeURL, accountKey: "new@example.com") + + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + + let refreshTransport = FakeCodexAppServerTransport() + try await refreshTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await refreshTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 44 + )) + + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + return refreshTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + await store.refreshAccountRateLimits(accountKey: "new@example.com") + let newAccount = store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" } + + #expect(newAccount?.rateLimits.isEmpty == true) + #expect(newAccount?.requiresReauthentication == true) + #expect(newAccount?.lastRateLimitError?.contains("Saved authentication is for") == true) + #expect(await refreshTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + ]) + } + + @Test func liveStoreAddAccountActivatesNewLoginWhenPersistedAccountsHaveNoActiveAccount() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: nil, + accounts: ["existing@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin(loginID: "login-new", authenticationURL: testAuthenticationURL) + let newAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus + )) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(newAccount, requiresOpenAIAuth: false) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + let externalURLOpener = FakeExternalURLOpener() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transportFactory: { codexHomeURL in + #expect(codexHomeURL == mainCodexHomeURL) + return transport + } + ) + + await store.start(forceRestartIfNeeded: true) + #expect(store.auth.selectedAccount == nil) + #expect(store.auth.persistedAccounts.map(\.accountKey) == ["existing@example.com"]) + + try await store.addAccount() + await transport.waitForRequestCount(5) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + try FileManager.default.createDirectory( + at: mainCodexHomeURL, + withIntermediateDirectories: true + ) + try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "login-new", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await transport.waitForRequestCount(9) + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.selectedAccount?.accountKey == "new@example.com" + && store.auth.selectedAccount?.rateLimits.first?.usedPercent == 20 + }) + + #expect(store.auth.persistedActiveAccountKey == "new@example.com") + #expect(try activeAccountKey(homeURL: homeURL) == "new@example.com") + #expect(store.auth.persistedAccounts.map(\.accountKey) == [ + "new@example.com", + "existing@example.com", + ]) + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountRead, + .accountRead, + .accountRateLimitsRead, + .accountRead, + ]) + } + + @Test func liveStoreAddAccountCancelsLoginWhenOpeningURLFails() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) + try await loginTransport.enqueueChatGPTLoginCancellation(.canceled) + var isolatedCodexHomeURL: URL? + let externalURLOpener = FakeExternalURLOpener( + failure: CodexReviewAPI.Error.io("Authentication presentation failed.") + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + externalURLOpener: externalURLOpener.open, + transportFactory: { codexHomeURL in + if codexHomeURL == mainCodexHomeURL { + return mainTransport + } + isolatedCodexHomeURL = codexHomeURL + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + return loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + do { + try await store.addAccount() + Issue.record("Expected URL presentation failure to propagate to the command.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .urlOpen(testAuthenticationURL)) + } + await loginTransport.waitForRequestCount(3) + + let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) + #expect( + failedMessage(from: store.auth.phase) + == "Failed to open the authentication URL: https://example.com/auth" + ) + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + await store.stop() + #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + #expect(await loginTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountLoginStart, + .accountLoginCancel, + ]) + } + + @Test func liveStoreIgnoresNonCodexRateLimitNotifications() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: transport, + email: "active@example.com", + planType: .pro, + usedPercent: 10 + ) + let activeAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )) + try await transport.enqueueAccount(activeAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(activeAccount, requiresOpenAIAuth: false) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + await store.refreshAccountRateLimits(accountKey: "active@example.com") + #expect(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.rateLimits.first?.usedPercent == 10 + }) + try await transport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( + limitID: "openai", + limitName: nil, + primary: .init( + usedPercent: 99, + windowDurationMinutes: 300, + resetsAtUnixSeconds: nil + ), + secondary: nil, + credits: nil, + individualLimit: nil, + planType: .pro, + reachedType: nil + ))) + try await transport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( + limitID: "codex", + limitName: nil, + primary: .init( + usedPercent: 11, + windowDurationMinutes: 300, + resetsAtUnixSeconds: nil + ), + secondary: nil, + credits: nil, + individualLimit: nil, + planType: nil, + reachedType: nil + ))) + #expect(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.rateLimits.first?.usedPercent == 11 + }) + #expect(store.auth.selectedAccount?.planType == "pro") + #expect(store.auth.selectedAccount?.rateLimits.map(\.usedPercent) == [11]) + } + + @Test func liveStoreDropsActiveRateRefreshAfterAccountGenerationQuiesces() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com", "second@example.com"] + ) + try writeSavedAccountAuth(homeURL: homeURL, accountKey: "second@example.com") + let firstTransport = FakeCodexAppServerTransport() + let firstAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro + )) + try await firstTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 99 + )) + try await firstTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + let secondTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: secondTransport, + email: "second@example.com", + planType: .plus, + usedPercent: 20 + ) + let rateReadGate = CodexAppServerTestGate() + let oldGenerationQuiesced = OneShotSignal() + var didPublishRuntime = false + var transports = [firstTransport, secondTransport] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerLifecycleHandler: { container in + if container != nil { + didPublishRuntime = true + } else if didPublishRuntime { + Task { await oldGenerationQuiesced.signal() } + } + }, + transportFactory: { codexHomeURL in + #expect(codexHomeURL == mainCodexHomeURL) + return transports.removeFirst() + } + ) + + await store.start(forceRestartIfNeeded: true) + let firstAuthBeforeRefresh = try savedAccountAuth( + homeURL: homeURL, + accountKey: "first@example.com" + ) + await firstTransport.holdNextIgnoringCancellation( + .accountRateLimitsRead, + gate: rateReadGate + ) + let refresh = Task { @MainActor in + await store.refreshAccountRateLimits(accountKey: "first@example.com") + } + await rateReadGate.waitUntilBlocked() + let accountSwitch = Task { @MainActor in + try await store.switchAccount( + CodexReviewKit.CodexReviewAccount(email: "second@example.com") + ) + } + await oldGenerationQuiesced.wait() + await rateReadGate.open() + await refresh.value + try await accountSwitch.value + + #expect(store.auth.selectedAccount?.accountKey == "second@example.com") + #expect(try savedAccountAuth( + homeURL: homeURL, + accountKey: "first@example.com" + ) == firstAuthBeforeRefresh) + #expect(store.auth.persistedAccounts.first(where: { + $0.accountKey == "first@example.com" + })?.rateLimits.first?.usedPercent != 99) + await store.stop() + } + + @Test func liveStoreClosesAdmissionWhileRuntimeAuthenticationReconciles() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + let secondAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )) + try await transport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + let accountReadGate = CodexAppServerTestGate() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.holdNextIgnoringCancellation(.accountRead, gate: accountReadGate) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + let refresh = Task { @MainActor in + await store.refreshAuthentication() + } + await accountReadGate.waitUntilBlocked() + + do { + _ = try await store.beginReview( + sessionID: "runtime-auth-reservation", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected runtime authentication reconciliation to close review admission.") + } catch {} + do { + try await store.reorderPersistedAccount(accountKey: "first@example.com", toIndex: 0) + Issue.record("Expected runtime authentication reconciliation to close account admission.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + + await accountReadGate.open() + await refresh.value + + #expect(store.serverState == .running) + #expect(store.auth.selectedAccount?.accountKey == "second@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + try await store.reorderPersistedAccount(accountKey: "first@example.com", toIndex: 0) + await store.stop() + } + + @Test func liveStoreDrainsAccountInvalidationAfterIsolatedLoginReleasesOwnership() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] + ) + let mainTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: mainTransport, email: "first@example.com") + let secondAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )) + let loginTransport = FakeCodexAppServerTransport() + try await loginTransport.enqueueChatGPTLogin( + loginID: "isolated-pending-drain", + authenticationURL: testAuthenticationURL + ) + try await loginTransport.enqueueChatGPTLoginCancellation(.canceled) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { codexHomeURL in + codexHomeURL == mainCodexHomeURL ? mainTransport : loginTransport + } + ) + + await store.start(forceRestartIfNeeded: true) + try await store.addAccount() + await loginTransport.waitForRequest(.accountLoginStart) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + try await mainTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + try #require(await waitUntil(timeout: .seconds(2)) { + store.backend.acceptsNewReviewOperations == false }) - let completeParams = try JSONDecoder().decode( - AppServerAPI.Account.Login.Complete.Params.self, - from: completeRequest.params - ) - #expect(completeParams.loginID == "login-1") - #expect(completeParams.callbackURL == "lynnpd.CodexReviewMonitor.auth://callback?code=abc") - try await transport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-1", success: true) - ) - try await transport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() - ) - await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" } - // The post-login rate-limit refresh lands asynchronously after the - // account update; wait for the full request sequence before asserting. - await transport.waitForRequestCount(8) - let methods = await transport.recordedRequests().map(\.method) - #expect(methods == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/login/complete", - "account/read", - "account/rateLimits/read", - ]) + try await mainTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await mainTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await mainTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + + do { + _ = try await store.beginReview( + sessionID: "isolated-pending-drain", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected an accepted account invalidation to close review admission immediately.") + } catch {} + do { + try await store.reorderPersistedAccount(accountKey: "first@example.com", toIndex: 0) + Issue.record("Expected an accepted account invalidation to close account admission immediately.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .accountMutationBlockedByAuthentication) + } + + await store.cancelAuthentication() + + #expect(store.serverState == .running) + #expect(await mainTransport.recordedRequests(for: .accountRead).count >= 4) + #expect(store.backend.acceptsNewReviewOperations) + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.selectedAccount?.accountKey == "second@example.com" + }) + #expect(await mainTransport.recordedRequests(for: .accountRead).count >= 2) await store.stop() } - @Test func liveStoreUsesNativeAuthenticationWhenServerDoesNotReturnNativeMetadata() async throws { - let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: nil - ), - for: "account/login/start" - ) - try await transport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "new@example.com", planType: "plus")), - for: "account/read" - ) - try await transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 20, windowDurationMins: 300) - )), - for: "account/rateLimits/read" + @Test func liveStoreRedrainsNewGenerationAfterSupersededDrainCompletes() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - let sessions = FakeWebAuthenticationSessions() - let externalURLOpener = FakeExternalURLOpener() + let oldTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: oldTransport, email: "first@example.com") + let firstAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro + )) + try await oldTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + try await oldTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + try await oldTransport.enqueueAccount(firstAccount, requiresOpenAIAuth: false) + let oldReadGate = CodexAppServerTestGate() + + let newTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: newTransport, email: "first@example.com") + let secondAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )) + try await newTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await newTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await newTransport.enqueueAccount(secondAccount, requiresOpenAIAuth: false) + try await newTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 30 + )) + let oldActivationGate = CodexAppServerTestGate() + let oldMCPHTTPServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:0/mcp")!, + activationGate: oldActivationGate + ) + let newActivationGate = CodexAppServerTestGate() + let newMCPHTTPServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:0/mcp")!, + activationGate: newActivationGate + ) + var transports = [oldTransport, newTransport] + var mcpHTTPServerCount = 0 let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, - externalURLOpener: externalURLOpener.open, - transport: transport + environment: ["HOME": homeURL.path], + mcpHTTPServerFactory: { _, configuration, _ in + defer { mcpHTTPServerCount += 1 } + if mcpHTTPServerCount == 0 { + _ = configuration + return oldMCPHTTPServer + } + return newMCPHTTPServer + }, + mcpHTTPServerBindChecker: { _ in }, + appServerCloser: { _ in }, + transportFactory: { _ in transports.removeFirst() } ) - await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - await store.addAccount() - await transport.waitForRequestCount(5) + let initialStart = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) + } + try #require(await waitUntil(timeout: .seconds(2)) { + await oldMCPHTTPServer.snapshot().activateCount == 1 + }) + let oldAccountReadCount = await oldTransport.recordedRequests(for: .accountRead).count + await oldTransport.holdNextIgnoringCancellation(.accountRead, gate: oldReadGate) + try await oldTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + try #require(await waitUntil(timeout: .seconds(2)) { + store.backend.acceptsNewReviewOperations == false + }) + await oldActivationGate.open() + await initialStart.value + try #require(await waitUntil(timeout: .seconds(2)) { + await oldTransport.recordedRequests(for: .accountRead).count + >= oldAccountReadCount + 1 + }) - #expect(store.auth.isAuthenticating) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) - let loginRequest = try #require(await transport.recordedRequests().first { - $0.method == "account/login/start" + await store.stop() + let restart = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) + } + try #require(await waitUntil(timeout: .seconds(2)) { + await newMCPHTTPServer.snapshot().activateCount == 1 }) - let loginParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: loginRequest.params) - #expect(loginParams.nativeWebAuthentication == .init( - callbackURLScheme: "lynnpd.CodexReviewMonitor.auth" - )) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) - try await transport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-1", success: true) - ) - try await transport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() - ) - await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" } - await transport.waitForRequestCount(7) - let methods = await transport.recordedRequests().map(\.method) - #expect(methods == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/read", - "account/rateLimits/read", - ]) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + try await newTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await newActivationGate.open() + await restart.value + + #expect(store.auth.selectedAccount?.accountKey == "first@example.com") + await oldReadGate.open() + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.selectedAccount?.accountKey == "second@example.com" + }) + + #expect(store.serverState == .running) + #expect(await newTransport.recordedRequests(for: .accountRead).count >= 4) await store.stop() } - @Test func liveStoreCancelsLoginWhenNativeSessionFactoryFails() async throws { + @Test func liveStoreFailsClosedWhenRuntimeAuthenticationRegistryCommitIsUnresolved() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] + ) let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") - ), - for: "account/login/start" + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )), + requiresOpenAIAuth: false ) - try await transport.enqueue( - AppServerAPI.Account.Login.Cancel.Response(), - for: "account/login/cancel" + let corruption = RegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) - var didCreateNativeSession = false - let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: { _, _, _, _ in - didCreateNativeSession = true - throw CodexReviewAPI.Error.io("Authentication presentation failed.") - }, - externalURLOpener: externalURLOpener.open, + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: corruption.corruptIfArmed, transport: transport ) await store.start(forceRestartIfNeeded: true) - await store.addAccount() - await transport.waitForRequestCount(6) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + corruption.arm() + await store.refreshAuthentication() - #expect(didCreateNativeSession) - #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") - #expect(externalURLOpener.openedURLs == []) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/login/cancel", - ]) + #expect(corruption.corruptionCount == 1) + #expect(store.auth.selectedAccount?.accountKey == "first@example.com") + #expect(store.auth.errorMessage?.contains("reconciliation remains pending") == true) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + )) + do { + _ = try await store.beginReview( + sessionID: "runtime-auth-sticky-failure", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected unresolved runtime authentication reconciliation to keep review admission closed.") + } catch {} await store.stop() } - @Test func liveStoreAddsAccountWithoutSwitchingExistingActiveAccount() async throws { + @Test func liveStoreDoesNotRecordStaleRuntimeAccountAfterAClaimedEffectFails() async throws { let homeURL = try temporaryHome() - let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response( - account: .init(email: "active@example.com", planType: "pro") - ), - for: "account/read" + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - try await mainTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - - let authTransport = FakeCodexAppServerTransport() - try await authTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await authTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") - ), - for: "account/login/start" + let replacement = BlockingRegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) - try await authTransport.enqueue( - AppServerAPI.Account.Login.Complete.Response(), - for: "account/login/complete" + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: replacement.blockCorruptAndFailIfArmed, + transport: transport ) - try await authTransport.enqueue( - AppServerAPI.Account.Read.Response( - account: .init(email: "new@example.com", planType: "plus") - ), - for: "account/read" + + await store.start(forceRestartIfNeeded: true) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") ) - try await authTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 25, windowDurationMins: 300) - )), - for: "account/rateLimits/read" + replacement.arm() + let refresh = Task { @MainActor in + await store.refreshAuthentication() + } + await replacement.waitUntilBlocked() + try Data(#"{"tokens":{"id_token":"third@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") ) - let refreshTransport = FakeCodexAppServerTransport() - let refreshGate = AsyncGate() - await refreshTransport.hold(method: "account/rateLimits/read", gate: refreshGate) - try await refreshTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await refreshTransport.enqueue( - AppServerAPI.Account.Read.Response( - account: .init(email: "new@example.com", planType: "plus") - ), - for: "account/read" + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + replacement.open() + await refresh.value + + let debtData = try Data(contentsOf: accountReconciliationDebtURL(homeURL: homeURL)) + let debt = try #require(JSONSerialization.jsonObject(with: debtData) as? [String: Any]) + #expect(debt["expectation"] as? String == "reconcileCurrentRuntime") + #expect({ if case .failed = store.serverState { return true }; return false }()) + await store.stop() + } + + @Test func liveStoreFinalShutdownSupersedesRuntimeAuthenticationRead() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - try await refreshTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 44, windowDurationMins: 300) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - var nonPrimaryTransports = [authTransport, refreshTransport] - var nonPrimaryRuntimeIndex = 0 - var refreshCodexHomeURL: URL? - let sessions = FakeWebAuthenticationSessions() - let externalURLOpener = FakeExternalURLOpener() + let readGate = CodexAppServerTestGate() + let stopCompletion = CompletionFlag() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, - externalURLOpener: externalURLOpener.open, - transportFactory: { codexHomeURL in - if codexHomeURL == mainCodexHomeURL { - return mainTransport - } - let runtimeIndex = nonPrimaryRuntimeIndex - nonPrimaryRuntimeIndex += 1 - try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) - if runtimeIndex == 0 { - try Data("{\"tokens\":{\"id_token\":\"login-token\"}}".utf8) - .write(to: codexHomeURL.appendingPathComponent("auth.json")) - } else { - refreshCodexHomeURL = codexHomeURL - } - return nonPrimaryTransports.removeFirst() - } + appServerCloser: { _ in }, + transport: transport ) await store.start(forceRestartIfNeeded: true) - #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + await transport.holdNextIgnoringCancellation(.accountRead, gate: readGate) + let refresh = Task { @MainActor in + await store.refreshAuthentication() + } + await readGate.waitUntilBlocked() + let stop = Task { @MainActor in + await store.stop() + await stopCompletion.complete() + } - await store.addAccount() - await authTransport.waitForNotificationStreamCount(1) - await authTransport.waitForRequestCount(2) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) - let loginRequest = try #require(await authTransport.recordedRequests().first { - $0.method == "account/login/start" - }) - let loginParams = try JSONDecoder().decode(AppServerAPI.Account.Login.Params.self, from: loginRequest.params) - #expect(loginParams.nativeWebAuthentication == .init( - callbackURLScheme: "lynnpd.CodexReviewMonitor.auth" - )) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) - await authTransport.waitForRequestCount(3) - let completeRequest = try #require(await authTransport.recordedRequests().first { - $0.method == "account/login/complete" + #expect(await waitUntil(timeout: .seconds(1)) { + await stopCompletion.isCompleted() }) - let completeParams = try JSONDecoder().decode( - AppServerAPI.Account.Login.Complete.Params.self, - from: completeRequest.params + #expect(store.serverState == .stopped) + await readGate.open() + await refresh.value + await stop.value + #expect(try activeAccountKey(homeURL: homeURL) == "first@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + } + + @Test func liveStoreFinalShutdownWaitsForClaimedRuntimeAuthenticationEffect() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - #expect(completeParams.loginID == "login-2") - #expect(completeParams.callbackURL == "lynnpd.CodexReviewMonitor.auth://callback?code=abc") - try await authTransport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-2", success: true) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + let replacement = BlockingRegistryReplacementProbe() + let stopCompletion = CompletionFlag() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: replacement.blockIfArmed, + appServerCloser: { _ in }, + transport: transport ) - try await authTransport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() + + await store.start(forceRestartIfNeeded: true) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") ) - await waitUntil { - store.auth.persistedAccounts.contains { $0.accountKey == "new@example.com" } - && store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25 - && store.auth.isAuthenticating == false + replacement.arm() + let refresh = Task { @MainActor in + await store.refreshAuthentication() + } + await replacement.waitUntilBlocked() + let stop = Task { @MainActor in + await store.stop() + await stopCompletion.complete() } + try await Task.sleep(for: .milliseconds(50)) + #expect(await stopCompletion.isCompleted() == false) + + replacement.open() + await refresh.value + await stop.value + #expect(await stopCompletion.isCompleted()) + #expect(try activeAccountKey(homeURL: homeURL) == "second@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + } - #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - #expect(store.auth.persistedActiveAccountKey == "active@example.com") - #expect(store.auth.persistedAccounts.map(\.accountKey).contains("new@example.com")) - #expect(store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 25) - #expect(await mainTransport.recordedRequests().map(\.method).contains("account/login/start") == false) - #expect(await authTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/login/start", - "account/login/complete", - "account/read", - "account/rateLimits/read", - ]) + @Test func liveStoreFailsClosedWhenCausalRuntimeAuthRefreshCannotRefetch() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] + ) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await transport.enqueueFailure( + .response(code: -32_000, message: "authoritative auth read failed"), + for: .accountRead + ) + let readGate = CodexAppServerTestGate() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) - async let refresh: Void = store.refreshAccountRateLimits(accountKey: "new@example.com") - await refreshTransport.waitForRequestCount(3) - let capturedRefreshCodexHomeURL = try #require(refreshCodexHomeURL) - try Data("{\"tokens\":{\"id_token\":\"refreshed-token\"}}".utf8) - .write(to: capturedRefreshCodexHomeURL.appendingPathComponent("auth.json")) - await refreshGate.open() - await refresh - await waitUntil { - store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 44 + await store.start(forceRestartIfNeeded: true) + await transport.holdNextIgnoringCancellation(.accountRead, gate: readGate) + let refresh = Task { @MainActor in + await store.refreshAuthentication() } + await readGate.waitUntilBlocked() + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + await readGate.open() + await refresh.value - #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - #expect(store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" }?.rateLimits.first?.usedPercent == 44) - #expect(try savedAccountAuth(homeURL: homeURL, accountKey: "new@example.com") == Data("{\"tokens\":{\"id_token\":\"refreshed-token\"}}".utf8)) - #expect(await refreshTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "account/rateLimits/read", - ]) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + #expect(store.auth.errorMessage?.contains("authoritative auth read failed") == true) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + await store.stop() } - @Test func liveStoreDoesNotApplySavedAccountRateLimitsFromDifferentAuth() async throws { + @Test func liveStoreStopsOnceWhenConnectionFailsDuringRuntimeAuthRead() async throws { let homeURL = try temporaryHome() - let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( homeURL: homeURL, - activeAccountKey: "active@example.com", - accounts: ["active@example.com", "new@example.com"] - ) - try writeSavedAccountAuth(homeURL: homeURL, accountKey: "new@example.com") - - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - try await mainTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + let readGate = CodexAppServerTestGate() + var closeCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerCloser: { _ in closeCount += 1 }, + transport: transport ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - let refreshTransport = FakeCodexAppServerTransport() - try await refreshTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await refreshTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" + await store.start(forceRestartIfNeeded: true) + await transport.holdNextIgnoringCancellation(.accountRead, gate: readGate) + let refresh = Task { @MainActor in + await store.refreshAuthentication() + } + await readGate.waitUntilBlocked() + await transport.failConnection(.closed) + try #require(await waitUntil(timeout: .seconds(1)) { + closeCount == 1 + }) + #expect({ + if case .failed = store.serverState { return true } + return false + }()) + + await readGate.open() + await refresh.value + #expect(closeCount == 1) + #expect(store.auth.selectedAccount?.accountKey == "first@example.com") + } + + @Test func liveStoreRefetchesStagingAuthAfterSubscribedInvalidation() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - try await refreshTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 44, windowDurationMins: 300) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus + )), + requiresOpenAIAuth: false + ) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let readGate = CodexAppServerTestGate() + await transport.holdNextIgnoringCancellation(.accountRead, gate: readGate) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transportFactory: { codexHomeURL in - if codexHomeURL == mainCodexHomeURL { - return mainTransport - } - return refreshTransport - } + transport: transport ) - await store.start(forceRestartIfNeeded: true) - await store.refreshAccountRateLimits(accountKey: "new@example.com") - let newAccount = store.auth.persistedAccounts.first { $0.accountKey == "new@example.com" } + let start = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) + } + await readGate.waitUntilBlocked() + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await readGate.open() + await start.value - #expect(newAccount?.rateLimits.isEmpty == true) - #expect(newAccount?.requiresReauthentication == true) - #expect(newAccount?.lastRateLimitError?.contains("Saved authentication is for") == true) - #expect(await refreshTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - ]) + #expect(store.serverState == .running) + #expect(store.auth.selectedAccount?.accountKey == "second@example.com") + #expect(await transport.recordedRequests(for: .accountRead).count == 2) + await store.stop() } - @Test func liveStoreAddAccountActivatesNewLoginWhenPersistedAccountsHaveNoActiveAccount() async throws { + @Test func liveStoreDropsActiveRateLimitsWhenBackendAccountChanges() async throws { let homeURL = try temporaryHome() - let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( homeURL: homeURL, - activeAccountKey: nil, - accounts: ["existing@example.com"] + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-new", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") - ), - for: "account/login/start" - ) - try await transport.enqueue( - AppServerAPI.Account.Login.Complete.Response(), - for: "account/login/complete" - ) - try await transport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "new@example.com", planType: "plus")), - for: "account/read" + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro + )), + requiresOpenAIAuth: false ) - try await transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 20, windowDurationMins: 300) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) - let externalURLOpener = FakeExternalURLOpener() - let sessions = FakeWebAuthenticationSessions() + let rateGate = CodexAppServerTestGate() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, - externalURLOpener: externalURLOpener.open, - transportFactory: { codexHomeURL in - #expect(codexHomeURL == mainCodexHomeURL) - return transport - } + transport: transport ) await store.start(forceRestartIfNeeded: true) - #expect(store.auth.selectedAccount == nil) - #expect(store.auth.persistedAccounts.map(\.accountKey) == ["existing@example.com"]) - - await store.addAccount() - await transport.waitForRequestCount(5) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - session.complete(with: URL(string: "lynnpd.CodexReviewMonitor.auth://callback?code=abc")!) - await transport.waitForRequestCount(6) - try await transport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-new", success: true) - ) - try await transport.emitServerNotification( - method: "account/updated", - params: EmptyResponse() + let savedAuthBefore = try savedAccountAuth( + homeURL: homeURL, + accountKey: "first@example.com" ) - await waitUntil { - store.auth.selectedAccount?.accountKey == "new@example.com" - && store.auth.selectedAccount?.rateLimits.first?.usedPercent == 20 + await transport.holdNextIgnoringCancellation(.accountRateLimitsRead, gate: rateGate) + let refresh = Task { @MainActor in + await store.refreshAccountRateLimits(accountKey: "first@example.com") } + await rateGate.waitUntilBlocked() + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + await rateGate.open() + await refresh.value - #expect(store.auth.persistedActiveAccountKey == "new@example.com") - #expect(try activeAccountKey(homeURL: homeURL) == "new@example.com") - #expect(store.auth.persistedAccounts.map(\.accountKey) == [ - "new@example.com", - "existing@example.com", - ]) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/login/complete", - "account/read", - "account/rateLimits/read", - ]) + #expect(store.auth.selectedAccount?.accountKey == "first@example.com") + #expect(store.auth.selectedAccount?.rateLimits.isEmpty == true) + #expect(try savedAccountAuth( + homeURL: homeURL, + accountKey: "first@example.com" + ) == savedAuthBefore) + await store.stop() } - @Test func liveStoreAddAccountCancelsLoginWhenNativeSessionFactoryFails() async throws { + @Test func liveStoreKeepsPrimaryLoginAdmissionUntilStablePublication() async throws { let homeURL = try temporaryHome() - let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) - try writeRegistry( - homeURL: homeURL, - activeAccountKey: "active@example.com", - accounts: ["active@example.com"] - ) - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "primary-admission", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - let loginTransport = FakeCodexAppServerTransport() - try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") - ), - for: "account/login/start" - ) - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Cancel.Response(), - for: "account/login/cancel" + requiresOpenAIAuth: false ) - var isolatedCodexHomeURL: URL? - let externalURLOpener = FakeExternalURLOpener() + let finalAccount = try CodexAppServerTestAccount(kind: .chatGPT( + email: "third@example.com", + planType: .pro + )) + try await transport.enqueueAccount(finalAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(finalAccount, requiresOpenAIAuth: false) + try await transport.enqueueAccount(finalAccount, requiresOpenAIAuth: false) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 15 + )) + let replacement = BlockingRegistryReplacementProbe() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: { _, _, _, _ in - throw CodexReviewAPI.Error.io("Authentication presentation failed.") - }, - externalURLOpener: externalURLOpener.open, - transportFactory: { codexHomeURL in - if codexHomeURL == mainCodexHomeURL { - return mainTransport - } - isolatedCodexHomeURL = codexHomeURL - try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) - return loginTransport - } + registryDestinationDidReplace: replacement.blockIfArmed, + transport: transport ) await store.start(forceRestartIfNeeded: true) - let previousFailureCount = store.auth.authenticationFailureCount - await store.addAccount() - await loginTransport.waitForRequestCount(3) + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + replacement.arm() + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "primary-admission", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + await replacement.waitUntilBlocked() + let accountReadCount = await transport.recordedRequests(for: .accountRead).count + await store.refreshAuthentication() + #expect(await transport.recordedRequests(for: .accountRead).count == accountReadCount) + do { + _ = try await store.beginReview( + sessionID: "primary-login-admission", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected primary login ownership to reject review admission.") + } catch {} - let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - #expect(store.auth.authenticationFailureCount == previousFailureCount + 1) - #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") - #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - #expect(externalURLOpener.openedURLs == []) + try Data(#"{"tokens":{"id_token":"third@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + replacement.open() + try #require(await waitUntil(timeout: .seconds(2)) { + store.auth.selectedAccount?.accountKey == "third@example.com" + }) + + #expect(try activeAccountKey(homeURL: homeURL) == "third@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) await store.stop() - #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) - #expect(await loginTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/login/start", - "account/login/cancel", - ]) } - @Test func liveStoreIgnoresNonCodexRateLimitNotifications() async throws { + @Test func liveStoreFailsPrimaryLoginClosedWithoutRecordingAnInvalidatedAccount() async throws { + let homeURL = try temporaryHome() + let codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry(homeURL: homeURL, activeAccountKey: nil, accounts: []) let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300), - planType: "pro" + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueChatGPTLogin( + loginID: "primary-invalidated-commit", + authenticationURL: testAuthenticationURL + ) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false + ) + let replacement = BlockingRegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: replacement.blockCorruptAndFailIfArmed, transport: transport ) await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - await waitUntil { - store.auth.selectedAccount?.rateLimits.first?.usedPercent == 10 - } - try await transport.emitServerNotification( - method: "account/rateLimits/updated", - params: TestRateLimitsUpdatedNotification(rateLimits: .init( - limitID: "openai", - primary: .init(usedPercent: 99, windowDurationMins: 300), - planType: "other" - )) - ) - try await transport.emitServerNotification( - method: "account/rateLimits/updated", - params: TestRateLimitsUpdatedNotification(rateLimits: .init( - limitID: "codex_bengalfox", - primary: .init(usedPercent: 11, windowDurationMins: 300) - )) - ) - await waitUntil { - store.auth.selectedAccount?.rateLimits.first?.usedPercent == 11 - } + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + replacement.arm() + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "primary-invalidated-commit", + completion: .succeeded + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) + try #require(await waitUntil(timeout: .seconds(2)) { + replacement.hasBlocked + }) + try Data(#"{"tokens":{"id_token":"third@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + replacement.open() - #expect(store.auth.selectedAccount?.planType == "pro") - #expect(store.auth.selectedAccount?.rateLimits.map(\.usedPercent) == [11]) + try #require(await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { return true } + return false + }) + let debtData = try Data(contentsOf: accountReconciliationDebtURL(homeURL: homeURL)) + let debt = try #require(JSONSerialization.jsonObject(with: debtData) as? [String: Any]) + #expect(debt["expectation"] as? String == "reconcileCurrentRuntime") + do { + _ = try await store.beginReview( + sessionID: "primary-invalidated-commit", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected unresolved primary login reconciliation to stay fail-closed.") + } catch {} + await store.stop() } @Test func liveStoreSwitchingAccountRestartsRuntimeAndCancelsRunningReviews() async throws { @@ -1233,73 +4485,73 @@ struct CodexReviewHostTests { try writeSavedAccountAuth(homeURL: homeURL, accountKey: "second@example.com") let firstTransport = FakeCodexAppServerTransport() - try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await firstTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "first@example.com", planType: "pro")), - for: "account/read" - ) - try await firstTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await firstTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await firstTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + try await firstTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) - try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-first", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-first"), for: "review/start") - try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt") + requiresOpenAIAuth: false + ) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await firstTransport.enqueueThreadStart(threadID: "thread-first", model: "gpt-5") + try await firstTransport.enqueueReviewStart(turnID: "turn-first", reviewThreadID: "thread-first") + try await firstTransport.enqueueSuccess(for: .turnInterrupt) let secondTransport = FakeCodexAppServerTransport() - try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await secondTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "second@example.com", planType: "plus")), - for: "account/read" - ) - try await secondTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await secondTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await secondTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 30, windowDurationMins: 300) + try await secondTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "second@example.com", + planType: .plus )), - for: "account/rateLimits/read" - ) + requiresOpenAIAuth: false + ) + try await secondTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await secondTransport.enqueueModels(.init(models: [])) + try await secondTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 30 + )) var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() } ) + let legacySecondAuthURL = mainCodexHomeURL + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(pathComponent(forAccountKey: "second@example.com"), isDirectory: true) + .appendingPathComponent("auth.json") + try FileManager.default.removeItem(at: legacySecondAuthURL) await store.start(forceRestartIfNeeded: true) async let reviewRead = store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-first" } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-first" + }) try await store.switchAccount(CodexReviewKit.CodexReviewAccount(email: "second@example.com")) let result = try await reviewRead await secondTransport.waitForRequestCount(2) - await firstTransport.waitForRequestCount(8) + await firstTransport.waitForRequestCount(7) - #expect(result.core.lifecycle.status == .cancelled) - #expect(result.core.lifecycle.cancellation?.message == "Account switched.") + #expect(result.presentation.status == .cancelled) + #expect(result.core.cancellation?.message == "Account switched.") #expect(store.auth.selectedAccount?.accountKey == "second@example.com") - #expect(await firstTransport.recordedRequests().map(\.method).contains("turn/interrupt")) - #expect(await secondTransport.recordedRequests().map(\.method).contains("account/read")) + #expect(await firstTransport.recordedRequests().map(\.request.operation).contains(.turnInterrupt)) + #expect(await secondTransport.recordedRequests().map(\.request.operation).contains(.accountRead)) } @Test func liveStoreSignOutRestartsRuntimeAndCancelsRunningReviews() async throws { @@ -1312,42 +4564,34 @@ struct CodexReviewHostTests { ) let firstTransport = FakeCodexAppServerTransport() - try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await firstTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await firstTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await firstTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await firstTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await firstTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + try await firstTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) - try await firstTransport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-active", model: "gpt-5"), for: "thread/start") - try await firstTransport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-active"), for: "review/start") - try await firstTransport.enqueue(EmptyResponse(), for: "turn/interrupt") - try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") + requiresOpenAIAuth: false + ) + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await firstTransport.enqueueThreadStart(threadID: "thread-active", model: "gpt-5") + try await firstTransport.enqueueReviewStart(turnID: "turn-active", reviewThreadID: "thread-active") + try await firstTransport.enqueueSuccess(for: .turnInterrupt) + try await firstTransport.enqueueSuccess(for: .accountLogout) let secondTransport = FakeCodexAppServerTransport() - try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await secondTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await secondTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await secondTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await secondTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await secondTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await secondTransport.enqueueModels(.init(models: [])) var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() @@ -1359,21 +4603,63 @@ struct CodexReviewHostTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-active" } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-active" + }) await store.logout() let result = try await reviewRead await secondTransport.waitForRequestCount(2) - let firstMethods = await firstTransport.recordedRequests().map(\.method) - let interruptIndex = try #require(firstMethods.firstIndex(of: "turn/interrupt")) - let logoutIndex = try #require(firstMethods.firstIndex(of: "account/logout")) + let firstMethods = await firstTransport.recordedRequests().map(\.request.operation) + let interruptIndex = try #require(firstMethods.firstIndex(of: .turnInterrupt)) + let logoutIndex = try #require(firstMethods.firstIndex(of: .accountLogout)) #expect(interruptIndex < logoutIndex) - #expect(result.core.lifecycle.status == .cancelled) - #expect(result.core.lifecycle.cancellation?.message == "Signed out.") + #expect(result.presentation.status == .cancelled) + #expect(result.core.cancellation?.message == "Signed out.") #expect(store.auth.selectedAccount == nil) #expect(store.auth.persistedAccounts.isEmpty) - #expect(await secondTransport.recordedRequests().map(\.method).contains("account/read")) + #expect(await secondTransport.recordedRequests().map(\.request.operation).contains(.accountRead)) + } + + @Test func liveStoreDoesNotPublishNoopSignOutAfterFinalShutdownRequest() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + let loadGate = ArmableAsyncHookGate() + let finalShutdownRequested = OneShotSignal() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + accountRegistryLoadDidBegin: { + await loadGate.waitIfArmed() + }, + finalShutdownDidRequest: { + await finalShutdownRequested.signal() + }, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + store.auth.updatePhase(.failed(.runtime(message: "preserved failure"))) + await loadGate.arm() + let signOut = Task { @MainActor in + try await store.signOutActiveAccount() + } + await loadGate.waitUntilBlocked() + let finalStop = Task { @MainActor in + await store.stop() + } + await finalShutdownRequested.wait() + + await loadGate.open() + try await signOut.value + await finalStop.value + + #expect(store.serverState == .stopped) + #expect(store.auth.errorMessage == "preserved failure") + #expect(store.auth.selectedAccount == nil) } @Test func liveStoreSwitchAccountFailsWhenSavedAuthIsMissing() async throws { @@ -1388,28 +4674,30 @@ struct CodexReviewHostTests { try FileManager.default.createDirectory(at: mainCodexHomeURL, withIntermediateDirectories: true) try originalAuth.write(to: mainCodexHomeURL.appendingPathComponent("auth.json")) - let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "first@example.com", planType: "pro")), - for: "account/read" - ) - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + let firstTransport = FakeCodexAppServerTransport() + try await firstTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false + ) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + let recoveryTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: recoveryTransport, + email: "first@example.com" ) + var transports = [firstTransport, recoveryTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transport: transport + transportFactory: { _ in transports.removeFirst() } ) await store.start(forceRestartIfNeeded: true) @@ -1420,6 +4708,7 @@ struct CodexReviewHostTests { #expect(store.auth.selectedAccount?.accountKey == "first@example.com") #expect(try activeAccountKey(homeURL: homeURL) == "first@example.com") #expect(try Data(contentsOf: mainCodexHomeURL.appendingPathComponent("auth.json")) == originalAuth) + await store.stop() } @Test func liveStoreStopLetsHTTPServerCancelSessionsBeforeDroppingBackend() async throws { @@ -1427,21 +4716,16 @@ struct CodexReviewHostTests { let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) let interruptGate = AsyncGate() let transport = FakeCodexAppServerTransport() - await transport.holdNext(method: "turn/interrupt", gate: interruptGate) - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") - try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") - try await transport.enqueue(EmptyResponse(), for: "thread/delete") + await transport.holdNext(.turnInterrupt, gate: interruptGate) + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + try await transport.enqueueSuccess(for: .turnInterrupt) + try await transport.enqueueSuccess(for: .threadDelete) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { store, _, _ in CodexReviewMCPHTTPServer( adapter: CodexReviewMCPServer(store: store), @@ -1456,106 +4740,248 @@ struct CodexReviewHostTests { ) await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) let endpoint = try #require(store.serverURL) let sessionID = try await initializeMCPSession(endpoint: endpoint) async let reviewRead = store.startReview( sessionID: sessionID, request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-1" } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" + }) let stopTask = Task { @MainActor in await store.stop() } let interruptStarted = await waitUntil(timeout: .seconds(2)) { - await transport.recordedRequests().map(\.method).contains("turn/interrupt") + await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt) } - let methodsBeforeInterruptCompletes = await transport.recordedRequests().map(\.method) + let methodsBeforeInterruptCompletes = await transport.recordedRequests().map(\.request.operation) await interruptGate.open() await stopTask.value let result = try await reviewRead #expect(interruptStarted) - #expect(methodsBeforeInterruptCompletes.contains("turn/interrupt")) - #expect(methodsBeforeInterruptCompletes.contains("thread/delete") == false) - #expect(result.core.lifecycle.status == .cancelled) - let methods = await transport.recordedRequests().map(\.method) - let interruptIndex = try #require(methods.firstIndex(of: "turn/interrupt")) - let deleteIndex = try #require(methods.firstIndex(of: "thread/delete")) + #expect(methodsBeforeInterruptCompletes.contains(.turnInterrupt)) + #expect(methodsBeforeInterruptCompletes.contains(.threadDelete) == false) + #expect(result.presentation.status == .cancelled) + let methods = await transport.recordedRequests().map(\.request.operation) + let interruptIndex = try #require(methods.firstIndex(of: .turnInterrupt)) + let deleteIndex = try #require(methods.firstIndex(of: .threadDelete)) #expect(interruptIndex < deleteIndex) } - @Test func liveStoreStopBoundsStuckReviewCancellationCleanup() async throws { + @Test func liveStoreStopJoinsReviewCancellationCleanup() async throws { let homeURL = try temporaryHome() let interruptGate = AsyncGate() let transport = FakeCodexAppServerTransport() - await transport.holdNext(method: "turn/interrupt", gate: interruptGate) - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue(AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), for: "thread/start") - try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") + await transport.holdNext(.turnInterrupt, gate: interruptGate) + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + try await transport.enqueueSuccess(for: .turnInterrupt) + try await transport.enqueueSuccess(for: .threadDelete) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - shutdownCleanupTimeout: .milliseconds(20), transport: transport ) await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) let reviewRead = Task { @MainActor in try await store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) } - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-1" } + try #require(await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" + }) - let startedAt = Date() - await store.stop() - let elapsed = Date().timeIntervalSince(startedAt) - let resultBeforeRemoteCleanupUnblocked = try await waitForTaskValue(reviewRead, timeout: .seconds(1)) + let stopFinished = CompletionFlag() + let stopTask = Task { @MainActor in + await store.stop() + await stopFinished.complete() + } + try #require(await waitUntil(timeout: .seconds(2)) { + await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt) + }) + #expect(await stopFinished.isCompleted() == false) await interruptGate.open() - let result = try #require(resultBeforeRemoteCleanupUnblocked) + await stopTask.value + let result = try await reviewRead.value + + #expect(await stopFinished.isCompleted()) + #expect(result.presentation.status == .cancelled) + #expect(await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt)) + } + + @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutAppServerCleanup() async throws { + let homeURL = try temporaryHome() + let networkMonitor = ManualCodexReviewNetworkMonitor() + let workerClock = ContinuousClock() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + try await transport.enqueueThreadResume(makeHostStoredThread(id: "thread-1")) + try await transport.enqueueSuccess(for: .turnInterrupt) + try await transport.enqueueSuccess(for: .threadDelete) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + networkMonitor: networkMonitor, + networkRecoveryPolicy: .init( + clock: .init( + now: { workerClock.now }, + sleep: { _ in } + ) + ), + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + let reviewRead = Task { @MainActor in + try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + try #require( + await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" + } + ) + + networkMonitor.yield(.init(status: .unsatisfied)) + try #require(await waitUntil(timeout: .seconds(2)) { + await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt) + }) + + let stopFinished = CompletionFlag() + let stopTask = Task { @MainActor in + await store.stop() + await stopFinished.complete() + } + await stopTask.value + let result = try await reviewRead.value + let methods = await transport.recordedRequests().map(\.request.operation) + + #expect(await stopFinished.isCompleted()) + #expect(result.presentation.status == .cancelled) + #expect(methods.contains(.turnInterrupt)) + #expect(methods.filter { $0 == .threadDelete }.count == 1) + } + + @Test func liveStoreMarksRuntimeFailedWhenAppServerNotificationStreamCloses() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + var observedLifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerLifecycleHandler: { container in + observedLifecycleStates.append(container != nil) + }, + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + await transport.failConnection(.closed) + try #require(await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { + return true + } + return false + }) + + guard case .failed(let message) = store.serverState else { + Issue.record("Expected failed server state.") + return + } + #expect(message.contains("The Codex app-server transport is closed.")) + #expect(store.serverURL == nil) + try #require(await waitUntil(timeout: .seconds(2)) { + observedLifecycleStates == [true, false] + }) + #expect(observedLifecycleStates == [true, false]) + } + + @Test func liveStoreDoesNotResumeActiveReviewAfterConnectionTerminates() async throws { + let homeURL = try temporaryHome() + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) + + await store.start(forceRestartIfNeeded: true) + await transport.waitForNotificationStreamCount(1) + let reviewRead = Task { @MainActor in + try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + try #require( + await waitUntil(timeout: .seconds(2)) { + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" + } + ) + + await transport.failConnection(.closed) + try #require( + await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { + return true + } + return false + } + ) + let result = try await reviewRead.value + let methods = await transport.recordedRequests().map(\.request.operation) - #expect(elapsed < 1) - #expect(result.core.lifecycle.status == .cancelled) - #expect(await transport.recordedRequests().map(\.method).contains("turn/interrupt")) + #expect(result.core.isTerminal) + #expect(store.serverURL == nil) + #expect(methods.contains(.threadResume) == false) + #expect(methods.contains(.turnInterrupt) == false) + #expect(methods.contains(.threadDelete) == false) } - @Test func liveStoreStopCleansRecoveryWaitingReviewWithoutAppServerCleanup() async throws { + @Test func liveStoreRetainsPreparedRestartOwnershipWhenConnectionTerminates() async throws { let homeURL = try temporaryHome() let networkMonitor = ManualCodexReviewNetworkMonitor() + let workerClock = ContinuousClock() let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await transport.enqueue( - AppServerAPI.Thread.Start.Response(threadID: "thread-1", model: "gpt-5"), - for: "thread/start" - ) - try await transport.enqueue( - AppServerAPI.Review.Start.Response(turnID: "turn-1", reviewThreadID: "review-thread-1"), - for: "review/start" - ) - try await transport.enqueueThreadResume(.init(id: "review-thread-1")) - try await transport.enqueue(EmptyResponse(), for: "turn/interrupt") - try await transport.enqueue(EmptyResponse(), for: "thread/delete") - try await transport.enqueue(EmptyResponse(), for: "thread/delete") + try await transport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") + try await transport.enqueueReviewStart(turnID: "turn-1", reviewThreadID: "thread-1") + try await transport.enqueueThreadResume(makeHostStoredThread(id: "thread-1")) + try await transport.enqueueSuccess(for: .turnInterrupt) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - shutdownCleanupTimeout: .seconds(1), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }), + networkRecoveryPolicy: .init( + clock: .init( + now: { workerClock.now }, + sleep: { _ in } + ) + ), transport: transport ) @@ -1567,59 +4993,35 @@ struct CodexReviewHostTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) } - try #require(await waitUntil(timeout: .seconds(2)) { store.reviewRuns.first?.core.run.turnID == "turn-1" }) - - networkMonitor.yield(.init(status: .unsatisfied)) try #require(await waitUntil(timeout: .seconds(2)) { - await transport.recordedRequests().map(\.method).contains("turn/interrupt") + store.reviewRuns.first?.core.attempt?.turnID.rawValue == "turn-1" }) - let stopFinished = CompletionFlag() - let stopTask = Task { @MainActor in - await store.stop() - await stopFinished.complete() - } - await stopTask.value - let result = try await reviewRead.value - let methods = await transport.recordedRequests().map(\.method) - - #expect(await stopFinished.isCompleted()) - #expect(result.core.lifecycle.status == .cancelled) - #expect(methods.contains("turn/interrupt")) - #expect(methods.filter { $0 == "thread/delete" }.count == 2) - } - - @Test func liveStoreMarksRuntimeFailedWhenAppServerNotificationStreamCloses() async throws { - let homeURL = try temporaryHome() - let transport = FakeCodexAppServerTransport() - try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await transport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await transport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transport: transport + networkMonitor.yield(.init(status: .unsatisfied)) + await transport.waitForRequest(.turnInterrupt) + try await transport.notificationEmitter.emitTurnCompleted( + threadID: "thread-1", + turn: try CodexAppServerTestTurn( + snapshot: .init(id: "turn-1", state: .interrupted), + items: [] + ) ) + await transport.failConnection(.closed) - await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - await transport.finishNotificationStreams(throwing: TestTransportClosedError()) - await waitUntil { + try #require(await waitUntil(timeout: .seconds(2)) { if case .failed = store.serverState { return true } return false - } + }) + let result = try await reviewRead.value + let journal = try await store.reviewThreadRetentionRegistry.snapshotForTesting() + let retained = try #require(journal.entries.first) - guard case .failed(let message) = store.serverState else { - Issue.record("Expected failed server state.") - return - } - #expect(message.contains("JSON-RPC transport is closed")) + #expect(result.core.isTerminal) + #expect(journal.entries.count == 1) + #expect(retained.attempts.count == 1) + #expect(retained.attempts[0].turnID.rawValue == "turn-1") #expect(store.serverURL == nil) } @@ -1632,37 +5034,22 @@ struct CodexReviewHostTests { accounts: ["active@example.com"] ) let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) let loginTransport = FakeCodexAppServerTransport() - try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: nil - ), - for: "account/login/start" - ) + try await loginTransport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) + try await loginTransport.enqueueChatGPTLoginCancellation(.canceled) let externalURLOpener = FakeExternalURLOpener() - let sessions = FakeWebAuthenticationSessions() var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { @@ -1676,24 +5063,25 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await mainTransport.waitForNotificationStreamCount(1) - await store.addAccount() - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() + try await store.addAccount() + await loginTransport.waitForRequestCount(2) let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path)) - #expect(externalURLOpener.openedURLs == []) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - await mainTransport.finishNotificationStreams(throwing: TestTransportClosedError()) - await waitUntil { + await mainTransport.failConnection(.closed) + try #require(await waitUntil(timeout: .seconds(2)) { if case .failed = store.serverState { return true } return false - } - await waitUntil { + }) + try #require(await waitUntil(timeout: .seconds(2)) { FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false - } + }) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + #expect(await loginTransport.recordedRequests(for: .accountLoginCancel).count == 1) + #expect(store.auth.isAuthenticating == false) } @Test func liveStoreRemovingActiveAccountClearsSharedAuthAndRestartsSignedOutRuntime() async throws { @@ -1708,38 +5096,31 @@ struct CodexReviewHostTests { .write(to: mainCodexHomeURL.appendingPathComponent("auth.json")) let firstTransport = FakeCodexAppServerTransport() - try await firstTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await firstTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await firstTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await firstTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") - try await firstTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + try await firstTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) - try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") + requiresOpenAIAuth: false + ) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await firstTransport.enqueueSuccess(for: .accountLogout) + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) let secondTransport = FakeCodexAppServerTransport() - try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await secondTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - try await secondTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" - ) - try await secondTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await secondTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await secondTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await secondTransport.enqueueModels(.init(models: [])) var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() @@ -1753,38 +5134,282 @@ struct CodexReviewHostTests { #expect(FileManager.default.fileExists(atPath: mainCodexHomeURL.appendingPathComponent("auth.json").path) == false) #expect(store.auth.selectedAccount == nil) #expect(store.auth.persistedAccounts.isEmpty) - #expect(await firstTransport.recordedRequests().map(\.method).contains("account/logout")) - #expect(await secondTransport.recordedRequests().map(\.method).contains("account/read")) + #expect(await firstTransport.recordedRequests().map(\.request.operation).contains(.accountLogout)) + #expect(await secondTransport.recordedRequests().map(\.request.operation).contains(.accountRead)) } - @Test func liveStoreClosesIsolatedLoginRuntimeWhenMainRuntimeIsUnavailable() async throws { + @Test func liveStoreFsyncsRemovalJournalBeforeUpstreamLogout() async throws { let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( homeURL: homeURL, activeAccountKey: "active@example.com", accounts: ["active@example.com"] ) - var isolatedCodexHomeURL: URL? + try Data("{\"tokens\":{\"id_token\":\"test\"}}".utf8) + .write(to: mainCodexHomeURL.appendingPathComponent("auth.json")) + let logoutGate = AsyncGate() + let mcpStopGate = CodexAppServerTestGate() + let firstMCPServer = MCPHTTPServerProbe( + endpoint: URL(string: "http://127.0.0.1:9417/mcp")!, + stopGate: mcpStopGate + ) + let firstTransport = FakeCodexAppServerTransport() + try await firstTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await firstTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await firstTransport.enqueueModels(.init(models: [])) + try await firstTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await firstTransport.enqueueSuccess(for: .accountLogout) + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await firstTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + await firstTransport.holdNext(.accountLogout, gate: logoutGate) + let secondTransport = FakeCodexAppServerTransport() + try await secondTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + try await secondTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await secondTransport.enqueueModels(.init(models: [])) + var transports = [firstTransport, secondTransport] + var mcpFactoryCallCount = 0 let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } + mcpHTTPServerFactory: { _, configuration, _ in + mcpFactoryCallCount += 1 + if mcpFactoryCallCount == 1 { + return firstMCPServer + } + return NoopMCPHTTPServer(endpoint: configuration.url()) + }, + mcpHTTPServerBindChecker: { _ in }, + transportFactory: { _ in transports.removeFirst() } + ) + await store.start(forceRestartIfNeeded: true) + try await firstTransport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( + limitID: "codex", + limitName: nil, + primary: .init( + usedPercent: 10, + windowDurationMinutes: 300, + resetsAtUnixSeconds: nil ), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, - transportFactory: { codexHomeURL in - isolatedCodexHomeURL = codexHomeURL - try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + secondary: nil, + credits: nil, + individualLimit: nil, + planType: nil, + reachedType: nil + ))) + try #require(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.rateLimits.first?.usedPercent == 10 + }) + + let removal = Task { + try await store.removeAccount(accountKey: "active@example.com") + } + await mcpStopGate.waitUntilBlocked() + #expect(await firstMCPServer.snapshot().stopCount == 1) + #expect( + FileManager.default.fileExists( + atPath: accountMutationJournalURL(homeURL: homeURL).path + ) == false + ) + #expect(await firstTransport.recordedRequests(for: .accountLogout).isEmpty) + await mcpStopGate.open() + await firstTransport.waitForRequestCount(5) + let journalData = try Data(contentsOf: accountMutationJournalURL(homeURL: homeURL)) + let journal = try #require(JSONSerialization.jsonObject(with: journalData) as? [String: Any]) + #expect(journal["phase"] as? String == "prepared") + #expect(journal["mayApplyIrreversibleLogout"] as? Bool == true) + let reviewRunCountBeforeRejectedAdmission = store.reviewRuns.count + do { + _ = try await store.beginReview( + sessionID: "transition-rejected-session", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("Expected account-transition admission to reject a new review before run publication.") + } catch { + #expect(error.localizedDescription.contains("changing accounts or stopping")) + } + #expect(store.reviewRuns.count == reviewRunCountBeforeRejectedAdmission) + let selectedAccountBeforeDroppedRefresh = store.auth.selectedAccount?.accountKey + let transitionedAccountProjection = try #require(store.auth.selectedAccount) + await store.refreshAuthentication() + #expect(store.auth.selectedAccount?.accountKey == selectedAccountBeforeDroppedRefresh) + #expect(store.auth.errorMessage == nil) + try await firstTransport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( + limitID: "codex", + limitName: nil, + primary: .init( + usedPercent: 12, + windowDurationMinutes: 300, + resetsAtUnixSeconds: nil + ), + secondary: nil, + credits: nil, + individualLimit: nil, + planType: nil, + reachedType: nil + ))) + #expect( + try Data(contentsOf: accountMutationJournalURL(homeURL: homeURL)) + == journalData + ) + try await firstTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + + let recoveryHomeURL = try temporaryHome() + let recoveryAccountsURL = recoveryHomeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + try FileManager.default.createDirectory( + at: recoveryAccountsURL, + withIntermediateDirectories: true + ) + try FileManager.default.copyItem( + at: mainCodexHomeURL + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent(pathComponent(forAccountKey: "active@example.com"), isDirectory: true), + to: recoveryAccountsURL + .appendingPathComponent(pathComponent(forAccountKey: "active@example.com"), isDirectory: true) + ) + let beforeRegistry = try #require(journal["beforeRegistry"] as? [String: Any]) + try JSONSerialization.data(withJSONObject: beforeRegistry).write( + to: recoveryAccountsURL.appendingPathComponent("registry.json") + ) + try journalData.write( + to: recoveryAccountsURL.appendingPathComponent("mutation-journal.json") + ) + let recoveredStore = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": recoveryHomeURL.path], + transport: FakeCodexAppServerTransport() + ) + #expect(recoveredStore.auth.errorMessage == nil) + #expect(recoveredStore.auth.persistedAccounts.isEmpty) + #expect(recoveredStore.auth.persistedActiveAccountKey == nil) + #expect( + FileManager.default.fileExists( + atPath: accountMutationJournalURL(homeURL: recoveryHomeURL).path + ) == false + ) + + await logoutGate.open() + try await removal.value + + #expect(transitionedAccountProjection.rateLimits.first?.usedPercent == 10) + let firstRuntimeAccountReadCount = await firstTransport.recordedRequests(for: .accountRead).count + #expect(firstRuntimeAccountReadCount == 2) + #expect(FileManager.default.fileExists(atPath: accountMutationJournalURL(homeURL: homeURL).path) == false) + #expect(try activeAccountKey(homeURL: homeURL) == nil) + } + + @Test func liveStoreAbortsPreparedRemovalWhenUpstreamLogoutFails() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + let originalAuth = try Data( + contentsOf: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) + try await transport.enqueueFailure( + .response(code: -32603, message: "logout unavailable"), + for: .accountLogout + ) + let replacementTransport = FakeCodexAppServerTransport() + try await replacementTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false + ) + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + var transports = [transport, replacementTransport] + var lifecycleStates: [Bool] = [] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerLifecycleHandler: { container in + lifecycleStates.append(container != nil) + }, + transportFactory: { _ in transports.removeFirst() } + ) + await store.start(forceRestartIfNeeded: true) + + do { + try await store.signOutActiveAccount() + Issue.record("Expected upstream logout failure to abort the prepared account mutation.") + } catch { + #expect(error.localizedDescription.contains("logout unavailable")) + } + + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(try activeAccountKey(homeURL: homeURL) == "active@example.com") + #expect( + try Data(contentsOf: mainCodexHomeURL.appendingPathComponent("auth.json")) + == originalAuth + ) + #expect( + FileManager.default.fileExists( + atPath: accountMutationJournalURL(homeURL: homeURL).path + ) == false + ) + #expect(lifecycleStates == [true, false, true]) + await store.stop() + } + + @Test func liveStoreRejectsAddAccountBeforeCreatingIsolatedRuntimeWhenMainRuntimeIsUnavailable() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "active@example.com", + accounts: ["active@example.com"] + ) + var runtimeFactoryCallCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in + runtimeFactoryCallCount += 1 return FakeCodexAppServerTransport() } ) - await store.addAccount() + do { + try await store.addAccount() + Issue.record("Expected unavailable main runtime to propagate to the add-account command.") + } catch { + #expect(error.localizedDescription == "The review runtime is changing accounts or stopping.") + } - let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - #expect(failedMessage(from: store.auth.phase) == "Review runtime is not running.") - #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) + #expect(runtimeFactoryCallCount == 0) + #expect(store.auth.selectedAccount?.accountKey == "active@example.com") + #expect(store.auth.errorMessage == nil) } @Test func liveStoreClosesIsolatedLoginRuntimeWhenLoginStartFails() async throws { @@ -1796,32 +5421,23 @@ struct CodexReviewHostTests { accounts: ["active@example.com"] ) let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) let loginTransport = FakeCodexAppServerTransport() - try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - await loginTransport.enqueueFailure( - code: -32603, - message: "login unavailable", - for: "account/login/start" + try await loginTransport.enqueueFailure( + .response(code: -32603, message: "login unavailable"), + for: .accountLoginStart ) var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -1833,10 +5449,23 @@ struct CodexReviewHostTests { ) await store.start(forceRestartIfNeeded: true) - await store.addAccount() + do { + try await store.addAccount() + Issue.record("Expected login-start failure to propagate to the command.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect( + failure + == .runtime( + message: "JSON-RPC request 2 (account/login/start) was rejected by the server: login unavailable" + ) + ) + } let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - #expect(failedMessage(from: store.auth.phase) == "login unavailable") + #expect( + failedMessage(from: store.auth.phase) + == "JSON-RPC request 2 (account/login/start) was rejected by the server: login unavailable" + ) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) } @@ -1849,37 +5478,21 @@ struct CodexReviewHostTests { accounts: ["active@example.com"] ) let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await mainTransport.enqueue( - AppServerAPI.Account.Read.Response(account: .init(email: "active@example.com", planType: "pro")), - for: "account/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro + )), + requiresOpenAIAuth: false ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + try await mainTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await mainTransport.enqueueModels(.init(models: [])) let loginTransport = FakeCodexAppServerTransport() - try await loginTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: nil - ), - for: "account/login/start" - ) + try await loginTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) let externalURLOpener = FakeExternalURLOpener() - let sessions = FakeWebAuthenticationSessions() var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - nativeAuthenticationConfiguration: .init( - callbackScheme: "lynnpd.CodexReviewMonitor.auth", - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: { NSWindow() } - ), - webAuthenticationSessionFactory: sessions.makeSession, externalURLOpener: externalURLOpener.open, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { @@ -1892,29 +5505,25 @@ struct CodexReviewHostTests { ) await store.start(forceRestartIfNeeded: true) - await store.addAccount() + try await store.addAccount() await loginTransport.waitForNotificationStreamCount(1) - let session = await sessions.waitForSession() - await session.waitUntilWaitingForCallback() - #expect(externalURLOpener.openedURLs == []) - try await loginTransport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification( - loginID: "login-2", - success: false, - error: "login completion failed" - ) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "login-2", + completion: .failed(message: "login completion failed") ) let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) - await waitUntil { failedMessage(from: store.auth.phase) == "login completion failed" } - await waitUntil { + try #require(await waitUntil(timeout: .seconds(2)) { + failedMessage(from: store.auth.phase) == "login completion failed" + }) + try #require(await waitUntil(timeout: .seconds(2)) { FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false - } + }) #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) - #expect(await loginTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/login/start", + #expect(await loginTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountLoginStart, ]) } @@ -1926,7 +5535,6 @@ struct CodexReviewHostTests { try FileManager.default.createDirectory(at: rawFallbackDirectoryURL, withIntermediateDirectories: true) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: FakeCodexAppServerTransport() ) store.auth.applyPersistedAccountStates([savedAccountPayload(from: account)]) @@ -1950,15 +5558,18 @@ struct CodexReviewHostTests { let dotDotDirectoryURL = accountsURL.appendingPathComponent("%2E%2E", isDirectory: true) try FileManager.default.createDirectory(at: dotDirectoryURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: dotDotDirectoryURL, withIntermediateDirectories: true) + try writeRegistryRecords( + homeURL: homeURL, + activeAccountKey: nil, + records: [ + ["accountKey": dotAccount.accountKey, "email": dotAccount.email], + ["accountKey": dotDotAccount.accountKey, "email": dotDotAccount.email], + ] + ) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: FakeCodexAppServerTransport() ) - store.auth.applyPersistedAccountStates([ - savedAccountPayload(from: dotAccount), - savedAccountPayload(from: dotDotAccount), - ]) try await store.removeAccount(accountKey: dotAccount.accountKey) try await store.removeAccount(accountKey: dotDotAccount.accountKey) @@ -1971,488 +5582,361 @@ struct CodexReviewHostTests { } } -private struct TestLoginCompletedNotification: Encodable, Sendable { - var loginID: String? - var success: Bool - var error: String? - - init(loginID: String?, success: Bool, error: String? = nil) { - self.loginID = loginID - self.success = success - self.error = error - } - - enum CodingKeys: String, CodingKey { - case loginID = "loginId" - case success - case error - } -} - -private struct TestAccountReadResponse: Encodable, Sendable { - var account: TestAccount - var requiresOpenAIAuth = false - - enum CodingKeys: String, CodingKey { - case account - case requiresOpenAIAuth = "requiresOpenaiAuth" - } -} - -private struct TestAccount: Encodable, Sendable { - var type: String -} - -private struct TestRateLimitsUpdatedNotification: Encodable, Sendable { - var rateLimits: AppServerAPI.Account.RateLimits.Snapshot -} - -private struct TestTransportClosedError: LocalizedError, Equatable, Sendable { - var errorDescription: String? { - "JSON-RPC transport is closed." - } -} - -private struct EmptyResponse: Codable, Equatable, Sendable { - init() {} -} - -private enum AppServerAPI { - enum Initialize { - struct Response: Codable, Equatable, Sendable { - var codexHome: String? - var userAgent: String? - - init(codexHome: String? = nil, userAgent: String? = nil) { - self.codexHome = codexHome - self.userAgent = userAgent - } - } - } - - enum Config { - enum Read { - struct Response: Codable, Equatable, Sendable { - var config: Snapshot - } - } - - struct Snapshot: Codable, Equatable, Sendable { - var model: String? - var reviewModel: String? - var modelReasoningEffort: String? - var serviceTier: String? - - enum CodingKeys: String, CodingKey { - case model - case reviewModel = "review_model" - case modelReasoningEffort = "model_reasoning_effort" - case serviceTier = "service_tier" - } - - init( - model: String? = nil, - reviewModel: String? = nil, - modelReasoningEffort: String? = nil, - serviceTier: String? = nil - ) { - self.model = model - self.reviewModel = reviewModel - self.modelReasoningEffort = modelReasoningEffort - self.serviceTier = serviceTier - } - } - } - - enum Model { - enum List { - struct Response: Codable, Equatable, Sendable { - var data: [CodexModel] - var nextCursor: String? - - init(data: [CodexModel], nextCursor: String? = nil) { - self.data = data - self.nextCursor = nextCursor - } - } +@MainActor +private func exerciseUnknownPrimaryCancellation( + previousAccountKey: String?, + observedAccountKey: String?, + expectedActiveAccountKey: String? +) async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + if let previousAccountKey { + let includesSwitchProbe = observedAccountKey == previousAccountKey + try writeRegistry( + homeURL: homeURL, + activeAccountKey: previousAccountKey, + accounts: includesSwitchProbe + ? [previousAccountKey, "other@example.com"] + : [previousAccountKey] + ) + if includesSwitchProbe { + try writeSavedAccountAuth( + homeURL: homeURL, + accountKey: "other@example.com" + ) } } - enum Thread { - enum Start { - struct Response: Codable, Equatable, Sendable { - var threadID: String - var model: String? - - enum CodingKeys: String, CodingKey { - case thread - case model - } - - private struct Thread: Codable, Equatable, Sendable { - var id: String - } - - init(threadID: String, model: String? = nil) { - self.threadID = threadID - self.model = model - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - threadID = try container.decode(Thread.self, forKey: .thread).id - model = try container.decodeIfPresent(String.self, forKey: .model) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Thread(id: threadID), forKey: .thread) - try container.encodeIfPresent(model, forKey: .model) - } - } - } + let initialTransport = FakeCodexAppServerTransport() + if let previousAccountKey { + try await initialTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: previousAccountKey, + planType: .pro + )), + requiresOpenAIAuth: false + ) + } else { + try await initialTransport.enqueueAccount(nil, requiresOpenAIAuth: false) } - - enum Review { - enum Start { - struct Response: Codable, Equatable, Sendable { - var turnID: String - var reviewThreadID: String? - - enum CodingKeys: String, CodingKey { - case turn - case reviewThreadID = "reviewThreadId" - } - - private struct Turn: Codable, Equatable, Sendable { - var id: String - } - - init(turnID: String, reviewThreadID: String? = nil) { - self.turnID = turnID - self.reviewThreadID = reviewThreadID - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - turnID = try container.decode(Turn.self, forKey: .turn).id - reviewThreadID = try container.decodeIfPresent(String.self, forKey: .reviewThreadID) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Turn(id: turnID), forKey: .turn) - try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID) - } - } - } + try await initialTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await initialTransport.enqueueModels(.init(models: [])) + if previousAccountKey != nil { + try await initialTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 10 + )) } + try await initialTransport.enqueueChatGPTLogin( + loginID: "unknown-primary-cancel", + authenticationURL: testAuthenticationURL + ) + try await initialTransport.enqueueFailure( + .response(code: -32_000, message: "cancel response lost"), + for: .accountLoginCancel + ) - enum Account { - enum Read { - struct Response: Codable, Equatable, Sendable { - var account: Snapshot? - var requiresOpenAIAuth: Bool - - enum CodingKeys: String, CodingKey { - case account - case requiresOpenAIAuth = "requiresOpenaiAuth" - } - - init(account: Snapshot? = nil, requiresOpenAIAuth: Bool = false) { - self.account = account - self.requiresOpenAIAuth = requiresOpenAIAuth - } - } - } - - struct Snapshot: Codable, Equatable, Sendable { - var type: String - var email: String? - var planType: String? - - init(type: String = "chatgpt", email: String? = nil, planType: String? = nil) { - self.type = type - self.email = email - self.planType = planType - } - } - - enum RateLimits { - struct Response: Codable, Equatable, Sendable { - var rateLimits: Snapshot - var rateLimitsByLimitID: [String: Snapshot]? - - enum CodingKeys: String, CodingKey { - case rateLimits - case rateLimitsByLimitID = "rateLimitsByLimitId" - } - - init(rateLimits: Snapshot, rateLimitsByLimitID: [String: Snapshot]? = nil) { - self.rateLimits = rateLimits - self.rateLimitsByLimitID = rateLimitsByLimitID - } - } - - struct Snapshot: Codable, Equatable, Sendable { - var limitID: String? - var primary: Window? - var secondary: Window? - var planType: String? - - enum CodingKeys: String, CodingKey { - case limitID = "limitId" - case primary - case secondary - case planType - } - - init( - limitID: String? = nil, - primary: Window? = nil, - secondary: Window? = nil, - planType: String? = nil - ) { - self.limitID = limitID - self.primary = primary - self.secondary = secondary - self.planType = planType - } - } - - struct Window: Codable, Equatable, Sendable { - var usedPercent: Int - var windowDurationMins: Int? - var resetsAt: Int64? - - init(usedPercent: Int, windowDurationMins: Int? = nil, resetsAt: Int64? = nil) { - self.usedPercent = usedPercent - self.windowDurationMins = windowDurationMins - self.resetsAt = resetsAt - } - } + let replacementTransport = FakeCodexAppServerTransport() + if let observedAccountKey { + try await replacementTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: observedAccountKey, + planType: .plus + )), + requiresOpenAIAuth: false + ) + } else { + try await replacementTransport.enqueueAccount(nil, requiresOpenAIAuth: false) + } + try await replacementTransport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await replacementTransport.enqueueModels(.init(models: [])) + if observedAccountKey != nil { + try await replacementTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 20 + )) + } + var transports = [initialTransport, replacementTransport] + if observedAccountKey == previousAccountKey, + let previousAccountKey { + let switchAwayTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: switchAwayTransport, + email: "other@example.com", + planType: .plus, + usedPercent: 25 + ) + let switchBackTransport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap( + on: switchBackTransport, + email: previousAccountKey, + planType: .pro, + usedPercent: 30 + ) + transports.append(switchAwayTransport) + transports.append(switchBackTransport) + } + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { codexHomeURL in + #expect(codexHomeURL == mainCodexHomeURL) + return transports.removeFirst() } + ) - enum Login { - struct Params: Codable, Equatable, Sendable { - var type: String - var apiKey: String? - var codexStreamlinedLogin: Bool - var nativeWebAuthentication: NativeWebAuthentication? - - init( - type: String = "chatgpt", - apiKey: String? = nil, - codexStreamlinedLogin: Bool = true, - nativeWebAuthentication: NativeWebAuthentication? = nil - ) { - self.type = type - self.apiKey = apiKey - self.codexStreamlinedLogin = codexStreamlinedLogin - self.nativeWebAuthentication = nativeWebAuthentication - } - } + await store.start(forceRestartIfNeeded: true) + if previousAccountKey == nil { + try await store.addAccount() + } else { + try await store.signIn() + } + await initialTransport.waitForRequest(.accountLoginStart) + let replacementAuthData = observedAccountKey.map { observedAccountKey in + Data(#"{"tokens":{"id_token":"\#(observedAccountKey)-rotated"}}"#.utf8) + } + if let replacementAuthData { + try replacementAuthData.write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + } - struct NativeWebAuthentication: Codable, Equatable, Sendable { - var callbackURLScheme: String + await store.cancelAuthentication() + + #expect(store.serverState == .running) + #expect(store.auth.selectedAccount?.accountKey == expectedActiveAccountKey) + let persistedActiveAccountKey = FileManager.default.fileExists( + atPath: accountRegistryURL(homeURL: homeURL).path + ) ? try activeAccountKey(homeURL: homeURL) : nil + #expect(persistedActiveAccountKey == expectedActiveAccountKey) + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + if let mutationProbeAccountKey = expectedActiveAccountKey ?? previousAccountKey { + try await store.reorderPersistedAccount( + accountKey: mutationProbeAccountKey, + toIndex: 0 + ) + } + if observedAccountKey == previousAccountKey, + let previousAccountKey, + let replacementAuthData { + #expect(try savedAccountAuth( + homeURL: homeURL, + accountKey: previousAccountKey + ) == replacementAuthData) + try await store.switchAccount( + CodexReviewKit.CodexReviewAccount(email: "other@example.com") + ) + try await store.switchAccount( + CodexReviewKit.CodexReviewAccount(email: previousAccountKey) + ) + #expect(try Data( + contentsOf: mainCodexHomeURL.appendingPathComponent("auth.json") + ) == replacementAuthData) + } + #expect(transports.isEmpty) + await store.stop() +} - enum CodingKeys: String, CodingKey { - case callbackURLScheme = "callbackUrlScheme" - } - } +private extension CodexAppServerTestTransport { + nonisolated var notificationEmitter: CodexAppServerTestNotificationEmitter { + CodexAppServerTestNotificationEmitter(transport: self) + } - enum Complete { - struct Params: Codable, Equatable, Sendable { - var loginID: String - var callbackURL: String + func enqueueThreadStart(threadID: CodexThreadID, model: String) throws { + try enqueueThreadStart(makeHostStoredThread(id: threadID, model: model)) + } - enum CodingKeys: String, CodingKey { - case loginID = "loginId" - case callbackURL = "callbackUrl" - } - } + func enqueueReviewStart( + turnID: CodexTurnID, + reviewThreadID: CodexThreadID + ) throws { + let turn = try CodexAppServerTestTurn( + snapshot: .init(id: turnID, state: .inProgress), + items: [] + ) + try enqueueReviewStart(turn, reviewThreadID: reviewThreadID) + } +} - struct Response: Codable, Equatable, Sendable { - init() {} - } - } +private func makeHostConfigurationReadResult( + model: String = "gpt-5" +) throws -> CodexAppServerTestConfigurationReadResult { + let metadata = try CodexAppServerTestConfigurationLayerMetadata( + source: .sessionFlags, + version: "host-test-config-v1" + ) + return try .init( + configuration: .init(model: model), + origins: ["model": metadata], + layers: [try .init( + metadata: metadata, + configuration: .object(["model": .string(model)]) + )] + ) +} - enum Response: Codable, Equatable, Sendable { - case apiKey - case chatgpt( - loginID: String, - authURL: String, - nativeWebAuthentication: NativeWebAuthentication? - ) - case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String) - case chatgptAuthTokens - - private enum CodingKeys: String, CodingKey { - case type - case loginID = "loginId" - case authURL = "authUrl" - case nativeWebAuthentication - case verificationURL = "verificationUrl" - case userCode - } +private func makeHostRateLimits( + planType: CodexAppServerTestPlanType?, + windowDurationMinutes: Int64, + usedPercent: Int32 +) throws -> CodexAppServerTestRateLimitsResponse { + let snapshot = try CodexAppServerTestRateLimitSnapshot( + limitID: "codex", + limitName: nil, + primary: .init( + usedPercent: usedPercent, + windowDurationMinutes: windowDurationMinutes, + resetsAtUnixSeconds: nil + ), + secondary: nil, + credits: nil, + individualLimit: nil, + planType: planType, + reachedType: nil + ) + return try .init( + primarySnapshot: snapshot, + snapshotsByLimitID: nil, + resetCredits: nil + ) +} - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch try container.decode(String.self, forKey: .type) { - case "apiKey": - self = .apiKey - case "chatgpt": - self = .chatgpt( - loginID: try container.decode(String.self, forKey: .loginID), - authURL: try container.decode(String.self, forKey: .authURL), - nativeWebAuthentication: try container.decodeIfPresent( - NativeWebAuthentication.self, - forKey: .nativeWebAuthentication - ) - ) - case "chatgptDeviceCode": - self = .chatgptDeviceCode( - loginID: try container.decode(String.self, forKey: .loginID), - verificationURL: try container.decode(String.self, forKey: .verificationURL), - userCode: try container.decode(String.self, forKey: .userCode) - ) - case "chatgptAuthTokens": - self = .chatgptAuthTokens - case let type: - throw DecodingError.dataCorruptedError( - forKey: .type, - in: container, - debugDescription: "Unsupported login response type: \(type)" - ) - } - } +private func enqueueActiveAccountBootstrap( + on transport: FakeCodexAppServerTransport, + email: String, + planType: CodexAppServerTestPlanType = .pro, + usedPercent: Int32 = 10 +) async throws { + let account = try CodexAppServerTestAccount(kind: .chatGPT( + email: email, + planType: planType + )) + try await transport.enqueueAccount(account, requiresOpenAIAuth: false) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: usedPercent + )) +} - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .apiKey: - try container.encode("apiKey", forKey: .type) - case .chatgpt(let loginID, let authURL, let nativeWebAuthentication): - try container.encode("chatgpt", forKey: .type) - try container.encode(loginID, forKey: .loginID) - try container.encode(authURL, forKey: .authURL) - try container.encodeIfPresent(nativeWebAuthentication, forKey: .nativeWebAuthentication) - case .chatgptDeviceCode(let loginID, let verificationURL, let userCode): - try container.encode("chatgptDeviceCode", forKey: .type) - try container.encode(loginID, forKey: .loginID) - try container.encode(verificationURL, forKey: .verificationURL) - try container.encode(userCode, forKey: .userCode) - case .chatgptAuthTokens: - try container.encode("chatgptAuthTokens", forKey: .type) - } - } - } +private func makeHostStoredThread( + id: CodexThreadID, + model: String = "gpt-5", + turns: [CodexAppServerTestTurn] = [] +) throws -> CodexAppServerTestStoredThread { + let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + preview: id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + status: .idle, + ephemeral: false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "host-test-cli", + source: .appServer + ), + runtimeMetadata: .init( + model: model, + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) +} - enum Cancel { - struct Response: Codable, Equatable, Sendable { - init() {} - } - } +@MainActor +private final class FakeExternalURLOpener { + private(set) var openedURLs: [URL] = [] + private let failure: (any Error)? + + init(failure: (any Error)? = nil) { + self.failure = failure + } + func open(_ url: URL) throws { + openedURLs.append(url) + if let failure { + throw failure } } } -@MainActor -private final class FakeWebAuthenticationSessions { - private var session: FakeWebAuthenticationSession? - private var sessionCount = 0 - private var waiters: [CheckedContinuation] = [] - - var createdSessionCount: Int { - sessionCount - } - - func makeSession( - url _: URL, - callbackScheme _: String, - browserSessionPolicy _: CodexReviewNativeAuthentication.Configuration.BrowserSessionPolicy, - presentationAnchorProvider _: @escaping @MainActor @Sendable () -> ASPresentationAnchor? - ) async throws -> any CodexReviewNativeAuthentication.WebSession { - let session = FakeWebAuthenticationSession() - sessionCount += 1 - self.session = session +private actor OneShotSignal { + private var isSignaled = false + private var waiters: [CheckedContinuation] = [] + + func signal() { + guard isSignaled == false else { + return + } + isSignaled = true let waiters = waiters self.waiters.removeAll(keepingCapacity: false) for waiter in waiters { - waiter.resume(returning: session) + waiter.resume() } - return session } - func waitForSession() async -> FakeWebAuthenticationSession { - if let session { - return session + func wait() async { + guard isSignaled == false else { + return } - return await withCheckedContinuation { continuation in - if let session { - continuation.resume(returning: session) + await withCheckedContinuation { continuation in + if isSignaled { + continuation.resume() } else { waiters.append(continuation) } } } -} - -@MainActor -private final class FakeExternalURLOpener { - private(set) var openedURLs: [URL] = [] - func open(_ url: URL) { - openedURLs.append(url) + func snapshot() -> Bool { + isSignaled } } -@MainActor -private final class FakeWebAuthenticationSession: CodexReviewNativeAuthentication.WebSession { - private var callbackContinuation: CheckedContinuation? - private var callbackWaiters: [CheckedContinuation] = [] - - func waitForCallbackURL() async throws -> URL { - try await withCheckedThrowingContinuation { continuation in - callbackContinuation = continuation - let waiters = callbackWaiters - callbackWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume() - } - } - } - - func cancel() async { - resume(throwing: CodexReviewNativeAuthenticationError.cancelled) - } +private actor ArmableAsyncHookGate { + private var armed = false + private let gate = CodexAppServerTestGate() - func closeFromAuthenticationWindow() async { - resume(throwing: CodexReviewNativeAuthenticationError.cancelled) + func arm() { + armed = true } - func complete(with url: URL) { - callbackContinuation?.resume(returning: url) - callbackContinuation = nil + func waitIfArmed() async { + guard armed else { return } + await gate.waitIgnoringCancellation() } - func waitUntilWaitingForCallback() async { - if callbackContinuation != nil { - return - } - await withCheckedContinuation { continuation in - if callbackContinuation != nil { - continuation.resume() - } else { - callbackWaiters.append(continuation) - } - } + func waitUntilBlocked() async { + await gate.waitUntilBlocked() } - private func resume(throwing error: Error) { - callbackContinuation?.resume(throwing: error) - callbackContinuation = nil + func open() async { + await gate.open() } } @@ -2500,6 +5984,10 @@ private func writeRegistryRecords( "accounts": records, ]) try data.write(to: registryURL) + if let activeAccountKey { + try Data("{\"tokens\":{\"id_token\":\"\(activeAccountKey)\"}}".utf8) + .write(to: codexHomeURL.appendingPathComponent("auth.json")) + } } private func writeSavedAccountAuth(homeURL: URL, accountKey: String) throws { @@ -2516,21 +6004,89 @@ private func writeSavedAccountAuth(homeURL: URL, accountKey: String) throws { } private func savedAccountAuth(homeURL: URL, accountKey: String) throws -> Data { - try Data(contentsOf: homeURL + let accountDirectoryURL = homeURL .appendingPathComponent(".codex_review", isDirectory: true) .appendingPathComponent("accounts", isDirectory: true) .appendingPathComponent(pathComponent(forAccountKey: accountKey), isDirectory: true) - .appendingPathComponent("auth.json")) + let registryURL = homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("registry.json") + let registryData = try Data(contentsOf: registryURL) + let registry = try #require(JSONSerialization.jsonObject(with: registryData) as? [String: Any]) + let records = try #require(registry["accounts"] as? [[String: Any]]) + let normalizedAccountKey = accountKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let record = try #require(records.first { record in + (record["accountKey"] as? String)?.lowercased() == normalizedAccountKey + }) + if let revision = record["immutableRevision"] as? String { + return try Data(contentsOf: accountDirectoryURL + .appendingPathComponent("revisions", isDirectory: true) + .appendingPathComponent("\(revision).json")) + } + return try Data(contentsOf: accountDirectoryURL.appendingPathComponent("auth.json")) } private func activeAccountKey(homeURL: URL) throws -> String? { - let registryURL = homeURL + let object = try accountRegistryObject(homeURL: homeURL) + return object["activeAccountKey"] as? String +} + +private func accountRegistryURL(homeURL: URL) -> URL { + homeURL .appendingPathComponent(".codex_review", isDirectory: true) .appendingPathComponent("accounts", isDirectory: true) .appendingPathComponent("registry.json") - let data = try Data(contentsOf: registryURL) - let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) - return object["activeAccountKey"] as? String +} + +private func accountMutationJournalURL(homeURL: URL) -> URL { + homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("mutation-journal.json") +} + +private func accountReconciliationDebtURL(homeURL: URL) -> URL { + homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("reconciliation-debt.json") +} + +private func accountReconciliationDebtURLForCodexHome(_ codexHomeURL: URL) -> URL { + codexHomeURL + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("reconciliation-debt.json") +} + +private func writeReconciliationDebt( + homeURL: URL, + expectedAccountKey: String +) throws { + let url = accountReconciliationDebtURL(homeURL: homeURL) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data(withJSONObject: [ + "expectation": "account", + "accountKey": expectedAccountKey, + "message": "test reconciliation debt", + "recordedAt": 0, + ]) + try data.write(to: url) +} + +private func temporaryHomeCleanupDebtURL(homeURL: URL) -> URL { + homeURL + .appendingPathComponent(".codex_review", isDirectory: true) + .appendingPathComponent("accounts", isDirectory: true) + .appendingPathComponent("temporary-home-cleanup-debt.json") +} + +private func accountRegistryObject(homeURL: URL) throws -> [String: Any] { + let data = try Data(contentsOf: accountRegistryURL(homeURL: homeURL)) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) } private func pathComponent(forAccountKey accountKey: String) -> String { @@ -2566,17 +6122,299 @@ private func initializeMCPSession(endpoint: URL) async throws -> String { } private func failedMessage(from phase: CodexReviewAuthModel.Phase) -> String? { - guard case .failed(let message) = phase else { + guard case .failed(let failure) = phase else { return nil } - return message + return failure.localizedDescription } -private final class NoopMCPHTTPServer: CodexReviewMCPHTTPServing, @unchecked Sendable { - private let endpoint: URL +private enum MCPHTTPServerProbeError: Error, Sendable { + case stagingFailed +} - init(endpoint: URL) { +private struct InjectedRegistryReplaceFailure: Error, Sendable {} + +private final class RegistryReplaceFailureProbe: @unchecked Sendable { + private let lock = NSLock() + private var isArmed = false + private var recordedFailureCount = 0 + + var failureCount: Int { + lock.withLock { recordedFailureCount } + } + + func arm() { + lock.withLock { isArmed = true } + } + + func failIfArmed() throws { + let shouldFail = lock.withLock { + guard isArmed else { return false } + isArmed = false + recordedFailureCount += 1 + return true + } + if shouldFail { + throw InjectedRegistryReplaceFailure() + } + } +} + +private final class RegistryReplacementCorruptingProbe: @unchecked Sendable { + private let lock = NSLock() + private let registryURL: URL + private var isArmed = false + private var recordedCorruptionCount = 0 + + init(registryURL: URL) { + self.registryURL = registryURL + } + + var corruptionCount: Int { + lock.withLock { recordedCorruptionCount } + } + + func arm() { + lock.withLock { isArmed = true } + } + + func corruptIfArmed() throws { + let shouldCorrupt = lock.withLock { + guard isArmed else { return false } + isArmed = false + recordedCorruptionCount += 1 + return true + } + guard shouldCorrupt else { return } + try Data("invalid registry".utf8).write(to: registryURL) + throw InjectedRegistryReplaceFailure() + } +} + +private final class BlockingRegistryReplacementProbe: @unchecked Sendable { + private let condition = NSCondition() + private var isArmed = false + private var isBlocked = false + private var isOpen = false + + func arm() { + condition.withLock { + isArmed = true + isBlocked = false + isOpen = false + } + } + + func blockIfArmed() { + condition.lock() + guard isArmed else { + condition.unlock() + return + } + isArmed = false + isBlocked = true + condition.broadcast() + while isOpen == false { + condition.wait() + } + condition.unlock() + } + + func waitUntilBlocked() async { + while condition.withLock({ isBlocked }) == false { + try? await Task.sleep(for: .milliseconds(5)) + } + } + + func open() { + condition.withLock { + isOpen = true + condition.broadcast() + } + } +} + +private final class BlockingRegistryReplacementCorruptingProbe: @unchecked Sendable { + private let condition = NSCondition() + private let registryURL: URL + private var isArmed = false + private var isBlocked = false + private var isOpen = false + + init(registryURL: URL) { + self.registryURL = registryURL + } + + var hasBlocked: Bool { + condition.withLock { isBlocked } + } + + func arm() { + condition.withLock { + isArmed = true + isBlocked = false + isOpen = false + } + } + + func blockCorruptAndFailIfArmed() throws { + condition.lock() + guard isArmed else { + condition.unlock() + return + } + isArmed = false + isBlocked = true + condition.broadcast() + while isOpen == false { + condition.wait() + } + condition.unlock() + try Data("invalid registry".utf8).write(to: registryURL) + throw InjectedRegistryReplaceFailure() + } + + func waitUntilBlocked() async { + while condition.withLock({ isBlocked }) == false { + try? await Task.sleep(for: .milliseconds(5)) + } + } + + func open() { + condition.withLock { + isOpen = true + condition.broadcast() + } + } +} + +private final class PathSynchronizationFailureProbe: @unchecked Sendable { + private let lock = NSLock() + private let path: String + private var remainingFailures: Int + private var failures = 0 + private var invocations = 0 + + init(path: String, failureCount: Int) { + self.path = path + remainingFailures = failureCount + } + + var recordedFailureCount: Int { + lock.withLock { failures } + } + + var recordedInvocationCount: Int { + lock.withLock { invocations } + } + + func arm(failureCount: Int) { + lock.withLock { + remainingFailures = failureCount + failures = 0 + invocations = 0 + } + } + + func failIfNeeded(_ url: URL) throws { + let shouldFail = lock.withLock { + guard url.standardizedFileURL.path == path else { + return false + } + invocations += 1 + guard remainingFailures > 0 else { return false } + remainingFailures -= 1 + failures += 1 + return true + } + if shouldFail { + throw InjectedRegistryReplaceFailure() + } + } +} + +private final class DirectorySynchronizationFailureProbe: @unchecked Sendable { + private let lock = NSLock() + private var shouldFail = true + private var recordedFailureCount = 0 + private var recordedSynchronizedPaths: [String] = [] + + var failureCount: Int { + lock.withLock { recordedFailureCount } + } + + var synchronizedPaths: [String] { + lock.withLock { recordedSynchronizedPaths } + } + + func failFirstSynchronization(_ url: URL) throws { + let shouldThrow = lock.withLock { + recordedSynchronizedPaths.append(url.path) + guard shouldFail else { return false } + shouldFail = false + recordedFailureCount += 1 + return true + } + if shouldThrow { + throw InjectedDirectorySynchronizationFailure() + } + } +} + +private struct InjectedDirectorySynchronizationFailure: Error, Sendable {} + +private actor MCPHTTPServerProbeState { + private(set) var stageCount = 0 + private(set) var activateCount = 0 + private(set) var stopCount = 0 + + func recordStage() { + stageCount += 1 + } + + func recordActivation() { + activateCount += 1 + } + + func recordStop() { + stopCount += 1 + } + + func snapshot() -> MCPHTTPServerProbe.Snapshot { + .init( + stageCount: stageCount, + activateCount: activateCount, + stopCount: stopCount + ) + } +} + +private final class MCPHTTPServerProbe: CodexReviewMCPHTTPServing, @unchecked Sendable { + struct Snapshot: Equatable, Sendable { + var stageCount: Int + var activateCount: Int + var stopCount: Int + } + + private let endpoint: URL + private let stageFailure: MCPHTTPServerProbeError? + private let stageGate: CodexAppServerTestGate? + private let activationGate: CodexAppServerTestGate? + private let stopGate: CodexAppServerTestGate? + private let state = MCPHTTPServerProbeState() + + init( + endpoint: URL, + stageFailure: MCPHTTPServerProbeError? = nil, + stageGate: CodexAppServerTestGate? = nil, + activationGate: CodexAppServerTestGate? = nil, + stopGate: CodexAppServerTestGate? = nil + ) { self.endpoint = endpoint + self.stageFailure = stageFailure + self.stageGate = stageGate + self.activationGate = activationGate + self.stopGate = stopGate } var url: URL { @@ -2585,23 +6423,49 @@ private final class NoopMCPHTTPServer: CodexReviewMCPHTTPServing, @unchecked Sen } } - func start() async throws {} + func start() async throws { + try await stage() + } - func stop() async {} -} + func stage() async throws { + await state.recordStage() + await stageGate?.waitIgnoringCancellation() + if let stageFailure { + throw stageFailure + } + } -@MainActor -private func waitUntil(_ condition: @escaping () -> Bool) async { - for _ in 0..<100 where condition() == false { - await Task.yield() + func activate() async { + await state.recordActivation() + await activationGate?.waitIgnoringCancellation() + } + + func stop() async { + await state.recordStop() + await stopGate?.waitIgnoringCancellation() + } + + func snapshot() async -> Snapshot { + await state.snapshot() } } -@MainActor -private func waitUntil(_ condition: @escaping () async -> Bool) async { - for _ in 0..<100 where await condition() == false { - await Task.yield() +private final class NoopMCPHTTPServer: CodexReviewMCPHTTPServing, @unchecked Sendable { + private let endpoint: URL + + init(endpoint: URL) { + self.endpoint = endpoint + } + + var url: URL { + get async { + endpoint + } } + + func start() async throws {} + + func stop() async {} } @MainActor @@ -2649,3 +6513,29 @@ private actor CompletionFlag { completed } } + +private actor ControlledHostRetentionJournal: ReviewThreadRetentionJournaling { + private var snapshot = ReviewThreadRetentionJournalSnapshot() + private var replacementFailure: String? + + func failReplacements(_ message: String?) { + replacementFailure = message + } + + func load() throws -> ReviewThreadRetentionJournalSnapshot { + snapshot + } + + func replace(with snapshot: ReviewThreadRetentionJournalSnapshot) throws { + if let replacementFailure { + throw ControlledHostRetentionJournalError(message: replacementFailure) + } + self.snapshot = snapshot + } +} + +private struct ControlledHostRetentionJournalError: LocalizedError, Sendable { + let message: String + + var errorDescription: String? { message } +} diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift deleted file mode 100644 index 6a46c045..00000000 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation -import Testing -@testable import CodexReviewKit - -@Suite("review backend event session") -struct ReviewBackendEventSessionTests { - @Test func emitsEventsAndRecordsLifecycleCallbacks() async throws { - let recorder = ReviewBackendEventSessionRecorder() - let session = ReviewBackendEventSession( - run: makeRun(), - callbacks: .init( - recordTurnStarted: { turnID in - await recorder.recordTurnStarted(turnID) - }, - recordFinished: { run, metrics in - await recorder.recordFinished(run: run, metrics: metrics) - } - ) - ) - let attempt = await session.attempt() - - await session.receive( - [ - .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5"), - .completed(finalReview: "No issues found."), - ], controlThreadID: "review-thread") - - #expect( - try await nextEvent(from: attempt.events) - == .started( - turnID: "turn-1", - reviewThreadID: "review-thread", - model: "gpt-5" - )) - #expect( - try await nextEvent(from: attempt.events) - == .completed(finalReview: "No issues found.")) - #expect(await recorder.startedTurnIDs() == ["turn-1"]) - #expect(await recorder.finishedRun() == makeRun()) - - let metrics = await session.metricsSnapshot() - #expect(metrics.routed == 1) - #expect(metrics.decoded == 1) - #expect(metrics.emitted == 2) - #expect(metrics.ignored == 0) - #expect(metrics.firstEventLatencyMs != nil) - #expect(metrics.terminalLatencyMs != nil) - #expect(await recorder.finishedMetrics() == metrics) - } -} - -private actor ReviewBackendEventSessionRecorder { - private var turnIDs: [String] = [] - private var completedRun: CodexReviewBackendModel.Review.Run? - private var completedMetrics: ReviewBackendEventSessionMetrics? - - func recordTurnStarted(_ turnID: String) { - turnIDs.append(turnID) - } - - func recordFinished( - run: CodexReviewBackendModel.Review.Run, - metrics: ReviewBackendEventSessionMetrics - ) { - completedRun = run - completedMetrics = metrics - } - - func startedTurnIDs() -> [String] { - turnIDs - } - - func finishedRun() -> CodexReviewBackendModel.Review.Run? { - completedRun - } - - func finishedMetrics() -> ReviewBackendEventSessionMetrics? { - completedMetrics - } -} - -private enum ReviewBackendEventSessionTestTimeout: Error { - case timedOut -} - -private func nextEvent( - from mailbox: BackendReviewEventMailbox, - timeout: Duration = .seconds(1) -) async throws -> CodexReviewBackendModel.Review.Event? { - try await withThrowingTaskGroup(of: CodexReviewBackendModel.Review.Event?.self) { group in - group.addTask { - try await mailbox.next() - } - group.addTask { - try await Task.sleep(for: timeout) - throw ReviewBackendEventSessionTestTimeout.timedOut - } - let event = try await group.next() - group.cancelAll() - return event ?? nil - } -} - -private func makeRun() -> CodexReviewBackendModel.Review.Run { - .init( - attemptID: "attempt-1", - threadID: "thread-1", - turnID: "turn-1", - reviewThreadID: "review-thread", - model: "gpt-5" - ) -} diff --git a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift index f71fb50d..47881c25 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewObservationAwaiterTests.swift @@ -16,7 +16,11 @@ struct ReviewObservationAwaiterTests { } await Task.yield() - run.updateStateForTesting(status: .succeeded, summary: "Done") + run.updateStateForTesting( + status: .succeeded, + endedAt: Date(timeIntervalSince1970: 20), + summary: "Done" + ) let result = await task.value #expect(result) @@ -33,7 +37,11 @@ struct ReviewObservationAwaiterTests { } await Task.yield() - run.updateStateForTesting(status: .cancelled, summary: "Stop") + run.updateStateForTesting( + status: .cancelled, + endedAt: Date(timeIntervalSince1970: 20), + summary: "Stop" + ) let result = await task.value #expect(result) @@ -50,11 +58,30 @@ struct ReviewObservationAwaiterTests { #expect(result == false) } + @Test func cancellationResumesWithoutWaitingForObservationOrTimeout() async { + let run = makeRunningRun() + let task = Task { @MainActor in + await ReviewObservationAwaiter.waitUntilTerminal( + run: run, + timeout: .seconds(60) + ) + } + await Task.yield() + + task.cancel() + + #expect(await task.value == false) + } + private func makeRunningRun() -> ReviewRunRecord { ReviewRunRecord.makeForTesting( id: "run-awaiter", targetSummary: "Uncommitted changes", + attemptID: "attempt-awaiter", + threadID: "thread-awaiter", + turnID: "turn-awaiter", status: .running, + startedAt: Date(timeIntervalSince1970: 10), summary: "Running review." ) } diff --git a/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift b/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift new file mode 100644 index 00000000..38ec108f --- /dev/null +++ b/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift @@ -0,0 +1,88 @@ +import Testing +@testable import CodexReviewKit +@testable import CodexReviewTesting + +@MainActor +@Suite("review network monitoring") +struct CodexReviewNetworkMonitoringTests { + @Test func manualMonitorKeepsOnlyNewestSnapshotAndCanFinishExplicitly() async throws { + let monitor = ManualCodexReviewNetworkMonitor() + var iterator = monitor.snapshots().makeAsyncIterator() + + #expect(await iterator.next()?.status == .satisfied) + + monitor.yield(.init(status: .unsatisfied)) + monitor.yield(.satisfied()) + + #expect(await iterator.next()?.status == .satisfied) + + monitor.finish() + #expect(await iterator.next() == nil) + } + + @Test func unexpectedNetworkSourceFinishFailsAnOtherwiseLiveReview() async throws { + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-network-source-finish", + sourceThreadID: "thread-network-source-finish", + activeTurnThreadID: "review-thread-network-source-finish", + turnID: "turn-network-source-finish" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let monitor = ManualCodexReviewNetworkMonitor() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-network-source-finish" }), + networkMonitor: monitor + ) + + async let result = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")) + ) + try #require( + await StoreSnapshotProbe(store: store).waitUntilRunStatus( + .running, + runID: "run-network-source-finish" + ) != nil + ) + + monitor.finish() + let read = try await result + + #expect(read.core.status == .failed) + #expect(read.core.failure == .connectivityObservationEnded) + } + + @Test func terminalCandidateWinsWhenNetworkSourceFinishesAtTheSameBoundary() async throws { + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-terminal-network-race", + sourceThreadID: "thread-terminal-network-race", + activeTurnThreadID: "review-thread-terminal-network-race", + turnID: "turn-terminal-network-race" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let monitor = ManualCodexReviewNetworkMonitor() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-terminal-network-race" }), + networkMonitor: monitor + ) + + async let result = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")) + ) + try #require( + await StoreSnapshotProbe(store: store).waitUntilRunStatus( + .running, + runID: "run-terminal-network-race" + ) != nil + ) + + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) + monitor.finish() + let read = try await result + + #expect(read.core.status == .succeeded) + } +} diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 6390d2c3..abda9be8 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -6,8 +6,31 @@ import CodexReviewTesting @Suite("Codex review store", .serialized) @MainActor struct CodexReviewStoreCommandTests { - @Test func reviewStartPublishesCompletedRunLifecycle() async throws { + @Test func reviewStartRejectsEmptyGeneratedRunIDBeforeInsertion() async { let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { " \n\t " }) + ) + + await #expect(throws: ReviewIdentityValidationError.empty(field: "runID")) { + try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + #expect(store.reviewRuns.isEmpty) + #expect(await backend.recordedCommands().isEmpty) + } + + @Test func reviewStartPublishesCompletedRunLifecycle() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), clock: .init(now: { Date(timeIntervalSince1970: 1) }), @@ -18,27 +41,38 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let read = try await result - #expect(read.runID == "run-1") - #expect(read.core.lifecycle.status == .succeeded) - #expect(store.listReviews(sessionID: nil).items.map(\.runID) == ["run-1"]) + #expect(read.runID.rawValue == "run-1") + #expect(read.presentation.status == .succeeded) + #expect(store.listReviews(sessionID: nil).items.map(\.runID.rawValue) == ["run-1"]) let commands = await backend.recordedCommands() #expect( commands.contains( .cleanupReview( - .init( - threadID: "thread-1", + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1" + activeTurnThreadID: "review-thread-1" )))) + #expect(commands.contains { command in + if case .cleanupRetainedReviews = command { true } else { false } + } == false) + let retained = try #require( + try await store.reviewThreadRetentionRegistry + .snapshotForTesting().entries.first + ) + #expect(retained.runID == makeRunID("run-1")) + #expect(retained.attempts.map(\.attemptID.rawValue) == ["attempt-1"]) } } @Test func boundedReviewStartReturnsRunningSnapshotAndCanBeAwaitedLater() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "bounded-start") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -51,22 +85,23 @@ struct CodexReviewStoreCommandTests { ) let running = try await result - #expect(running.runID == "run-1") - #expect(running.core.lifecycle.status == .running) + #expect(running.runID.rawValue == "run-1") + #expect(running.presentation.status == .running) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let final = try await store.awaitReview( sessionID: "session-1", - runID: "run-1", + runID: makeRunID("run-1"), timeout: .seconds(1) ) - #expect(final.core.lifecycle.status == .succeeded) + #expect(final.presentation.status == .succeeded) } } @Test func awaitReviewReturnsWhenRunningRunCompletes() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "await-completion") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -81,18 +116,19 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "run-1", + runID: makeRunID("run-1"), timeout: .seconds(1) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let final = try await awaited - #expect(final.core.lifecycle.status == .succeeded) + #expect(final.presentation.status == .succeeded) } } @Test func awaitReviewReturnsWhenRunningRunIsCancelled() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "await-cancellation") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -107,22 +143,23 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "run-1", + runID: makeRunID("run-1"), timeout: .seconds(1) ) _ = try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) let final = try await awaited - #expect(final.core.lifecycle.status == .cancelled) - #expect(final.core.lifecycleMessage == "Stop") + #expect(final.presentation.status == .cancelled) + #expect(final.presentation.lifecycle.message == "Stop") } } @Test func awaitReviewReturnsCurrentSnapshotOnTimeout() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "await-timeout") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -137,16 +174,17 @@ struct CodexReviewStoreCommandTests { let snapshot = try await store.awaitReview( sessionID: "session-1", - runID: "run-1", + runID: makeRunID("run-1"), timeout: .milliseconds(10) ) - #expect(snapshot.core.lifecycle.status == .running) + #expect(snapshot.presentation.status == .running) } } @Test func awaitReviewReturnsWhenLocalTerminationUpdatesRunLifecycle() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "local-termination") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -161,7 +199,7 @@ struct CodexReviewStoreCommandTests { async let awaited = store.awaitReview( sessionID: "session-1", - runID: "run-1", + runID: makeRunID("run-1"), timeout: .seconds(1) ) await Task.yield() @@ -170,8 +208,8 @@ struct CodexReviewStoreCommandTests { ) let final = try await awaited - #expect(final.core.lifecycle.status == .failed) - #expect(final.core.lifecycleMessage == "Failed to cancel review: Review runtime stopped.") + #expect(final.presentation.status == .failed) + #expect(final.presentation.lifecycle.message == "Review runtime stopped.") } } @@ -189,7 +227,8 @@ struct CodexReviewStoreCommandTests { } @Test func reviewStartPassesEffectiveSettingsModelToBackend() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "effective-model") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend( reviewBackend: backend, @@ -202,7 +241,7 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) _ = try await result let commands = await backend.recordedCommands() @@ -217,7 +256,8 @@ struct CodexReviewStoreCommandTests { } @Test func reviewStartDoesNotNeedProgressEventsForLifecycle() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "no-progress") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -231,14 +271,16 @@ struct CodexReviewStoreCommandTests { let runningSnapshot = try #require(await probe.waitUntilRunStatus(.running, runID: "run-1")) #expect(runningSnapshot.run("run-1")?.summary == "Review started.") - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let read = try await result - #expect(read.core.lifecycleMessage == "Review completed.") + #expect(read.presentation.lifecycle.message == "Review completed.") } } @Test func newlyStartedReviewAppearsBeforeExistingRunsAcrossWorkspaces() async throws { - let backend = FakeCodexReviewBackend() + let firstAttempt = makeAttempt(fixtureID: "workspace-order-first") + let secondAttempt = makeAttempt(fixtureID: "workspace-order-second") + let backend = FakeCodexReviewBackend(plannedAttempt: firstAttempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -247,15 +289,16 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/old-project", target: .baseBranch("main")) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: firstAttempt) _ = try await first - await backend.finishEvents() + await backend.finishEvents(for: firstAttempt) + await backend.planNextAttempt(secondAttempt) async let second = store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/new-project", target: .uncommittedChanges) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: secondAttempt) _ = try await second #expect(store.orderedReviewRuns.map(\.cwd) == ["/tmp/new-project", "/tmp/old-project"]) @@ -263,7 +306,9 @@ struct CodexReviewStoreCommandTests { } @Test func newlyStartedReviewAppearsBeforeExistingRunsInWorkspace() async throws { - let backend = FakeCodexReviewBackend() + let firstAttempt = makeAttempt(fixtureID: "same-workspace-order-first") + let secondAttempt = makeAttempt(fixtureID: "same-workspace-order-second") + let backend = FakeCodexReviewBackend(plannedAttempt: firstAttempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -272,15 +317,16 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: firstAttempt) _ = try await first - await backend.finishEvents() + await backend.finishEvents(for: firstAttempt) + await backend.planNextAttempt(secondAttempt) async let second = store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: secondAttempt) _ = try await second #expect( @@ -292,7 +338,8 @@ struct CodexReviewStoreCommandTests { } @Test func runningReviewElapsedSecondsUsesInjectedClock() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "elapsed-seconds") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let clock = MutableTestClock(Date(timeIntervalSince1970: 1)) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -307,37 +354,99 @@ struct CodexReviewStoreCommandTests { try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) clock.current = Date(timeIntervalSince1970: 13) - #expect(try store.readReview(runID: "run-1").elapsedSeconds == 12) + #expect(try store.readReview(runID: makeRunID("run-1")).elapsedSeconds == 12) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) _ = try await result } } - @Test func newlyStartedReviewUsesSortOrderAboveCurrentMaximum() async throws { + @Test func allowedRunIDsIntersectListAndSelectorBeforeLimit() throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) + let first = ReviewRunRecord.makeForTesting( + id: "run-1", + sessionID: "session-1", + cwd: "/tmp/project", + targetSummary: "First", + attemptID: "attempt-1", + threadID: "thread-1", + reviewThreadID: "review-thread-1", + turnID: "turn-1", + status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1), + endedAt: Date(timeIntervalSince1970: 2), + summary: "Done" + ) + let second = ReviewRunRecord.makeForTesting( + id: "run-2", + sessionID: "session-1", + cwd: "/tmp/project", + targetSummary: "Second", + attemptID: "attempt-2", + threadID: "thread-2", + reviewThreadID: "review-thread-2", + turnID: "turn-2", + status: .succeeded, + startedAt: Date(timeIntervalSince1970: 3), + endedAt: Date(timeIntervalSince1970: 4), + summary: "Done" + ) + store.loadForTesting( + serverState: .running, + reviewRuns: [first, second] + ) + let allowed = Set([makeRunID("run-1")]) + + let listed = store.listReviews( + sessionID: "session-1", + cwd: "/tmp/project", + limit: 1, + allowedRunIDs: allowed + ) + let selected = try store.resolveRun( + sessionID: "session-1", + selector: .init(cwd: "/tmp/project"), + allowedRunIDs: allowed + ) + + #expect(listed.items.map(\.runID) == [makeRunID("run-1")]) + #expect(selected.id == makeRunID("run-1")) + } + + @Test func newlyStartedReviewUsesSortOrderAboveCurrentMaximum() async throws { + let attempt = makeAttempt(fixtureID: "sort-order") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + ) try await withStoreCommandTestCleanup(backend: backend, store: store) { let existing = ReviewRunRecord.makeForTesting( id: "run-existing", cwd: "/tmp/project", targetSummary: "Existing", + attemptID: "attempt-existing", + threadID: "thread-existing", + reviewThreadID: "review-thread-existing", + turnID: "turn-existing", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1), + endedAt: Date(timeIntervalSince1970: 2), summary: "Done" ) store.loadForTesting( serverState: .running, reviewRuns: [existing] ) - store.reviewRun(id: "run-existing")?.sortOrder = 10 + store.reviewRun(id: makeRunID("run-existing"))?.sortOrder = 10 async let result = store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) _ = try await result #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") @@ -345,7 +454,13 @@ struct CodexReviewStoreCommandTests { } @Test func cancelRunningReviewUsesBackendInterruptAndPublicState() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -357,26 +472,32 @@ struct CodexReviewStoreCommandTests { ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) let cancel = try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.cancelled("Stop")) + await backend.yield(.cancelled("Stop"), for: attempt) _ = try await result #expect(cancel.cancelled) - #expect(try store.readReview(runID: "run-1").core.lifecycle.status == .cancelled) + #expect(try store.readReview(runID: makeRunID("run-1")).presentation.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains( .interruptReview( - .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ), .init(message: "Stop") ))) } } @Test func pendingCancellationIsNoLongerCancellable() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "pending-cancellation") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -391,32 +512,58 @@ struct CodexReviewStoreCommandTests { try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) async let cancellation = store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) try await backend.waitForInterruptReview(timeout: .seconds(2)) - #expect(try store.readReview(runID: "run-1").cancellable == false) + #expect(try store.readReview(runID: makeRunID("run-1")).cancellable == false) #expect(store.listReviews().items.first?.cancellable == false) #expect(store.hasCancellableReview(forChatID: "thread-1") == false) - let duplicate = try await store.cancelReview( - runID: "run-1", + async let duplicate = store.cancelReview( + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop again") ) - #expect(duplicate.cancelled == false) - #expect(duplicate.core.lifecycle.cancellation?.message == "Stop") await interruptGate.open() _ = try await cancellation + let joined = try await duplicate let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycle.cancellation?.message == "Stop") + #expect(joined.cancelled) + #expect(joined.core.cancellation?.message == "Stop") + #expect(read.presentation.status == .cancelled) + #expect(read.core.cancellation?.message == "Stop") } } + @Test func workerConnectivitySnapshotUsesInjectedMonotonicClock() { + let presentationDate = Date(timeIntervalSince1970: 42) + let instant = ContinuousClock().now.advanced(by: .seconds(5)) + let snapshot = ReviewWorkerConnectivitySnapshot( + .init( + status: .requiresConnection, + observedAt: presentationDate + ), + clock: .init( + now: { instant }, + sleep: { _ in } + ) + ) + + switch snapshot.connectivity { + case .outage: + break + case .satisfied: + Issue.record("A requires-connection snapshot must normalize to a worker outage.") + } + #expect(snapshot.observedAt == instant) + #expect(snapshot.presentationDate == presentationDate) + } + @Test func transientNetworkOutageDoesNotRecoverReview() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "transient-outage") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let networkMonitor = ManualCodexReviewNetworkMonitor() let debounceGate = AsyncGate() let store = CodexReviewStore.makeTestingStore( @@ -426,7 +573,9 @@ struct CodexReviewStoreCommandTests { networkRecoveryPolicy: .init( outageDebounce: .seconds(10), recoverySettle: .seconds(1), - sleep: { _ in await debounceGate.wait() } + clock: testReviewWorkerClock { _ in + try? await debounceGate.wait() + } ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -460,20 +609,21 @@ struct CodexReviewStoreCommandTests { } } == false) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) + #expect(read.presentation.status == .succeeded) } } @Test func sustainedNetworkOutageInterruptsForRecoveryWithoutTerminalRun() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "sustained-outage") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -484,31 +634,35 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - let running = try store.readReview(runID: "run-1") - #expect(running.core.lifecycle.status == .running) - #expect(running.core.lifecycleMessage == "Network unavailable; waiting to reconnect.") - _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) - await backend.yield(.cancelled("Stop")) + let running = try store.readReview(runID: makeRunID("run-1")) + #expect(running.presentation.status == .running) + #expect(running.presentation.lifecycle == .preparingRestart) + _ = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) + await backend.yield(.cancelled("Stop"), for: attempt) _ = try await result } } @Test func networkRecoveryRepeatedSatisfiedSnapshotsRestartAfterLatestSettle() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let settleGate = AsyncGate() let sleeper = ControlledTestSleeper(gate: settleGate) @@ -519,7 +673,7 @@ struct CodexReviewStoreCommandTests { networkRecoveryPolicy: .init( outageDebounce: .seconds(10), recoverySettle: .seconds(1), - sleep: { _ in await sleeper.sleep() } + clock: testReviewWorkerClock { _ in await sleeper.sleep() } ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -534,7 +688,14 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) #expect( await waitUntil { - store.reviewRun(id: "run-1")?.core.lifecycleMessage == "Network restored; restarting review." + guard let lifecycle = store.reviewRun(id: makeRunID("run-1"))? + .presentation.lifecycle else { + return false + } + if case .waitingForNetwork = lifecycle { + return true + } + return false }) networkMonitor.yield(.satisfied()) await settleGate.open() @@ -544,33 +705,34 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.run.turnID == "turn-2") + #expect(read.presentation.status == .succeeded) + #expect(read.core.attempt?.turnID.rawValue == "turn-2") } } - @Test func networkRecoveryUsesActualStartedTurn() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", - turnID: "turn-response", - reviewThreadID: "review-thread-1", + @Test func networkRecoveryUsesAuthoritativeStartAttempt() async throws { + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", + turnID: "turn-actual", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-recovered", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -578,70 +740,62 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - await backend.yield( - .started( - turnID: "turn-actual", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ), for: initialRun) - #expect( - await waitUntil { - store.reviewRun(id: "run-1")?.core.run.turnID == "turn-actual" - }) + try #require(await waitForRunAttemptActivation(store: store, run: initialRun)) networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) let commandsAfterInterrupt = await backend.recordedCommands() - let interruptedRuns = commandsAfterInterrupt.compactMap { command -> CodexReviewBackendModel.Review.Run? in + let interruptedRuns = commandsAfterInterrupt.compactMap { command -> ReviewAttempt? in if case .prepareReviewRestart(let run) = command { return run } return nil } - #expect(interruptedRuns.last?.turnID == "turn-actual") + #expect(interruptedRuns.last?.turnID.rawValue == "turn-actual") networkMonitor.yield(.satisfied()) try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) let commandsAfterRecovery = await backend.recordedCommands() - let recoveredFromRuns = commandsAfterRecovery.compactMap { command -> CodexReviewBackendModel.Review.Run? in + let recoveredFromRuns = commandsAfterRecovery.compactMap { command -> ReviewAttempt? in if case .restartPreparedReview(let token, _) = command { - return token.interruptedRun + return token.interruptedAttempt } return nil } - #expect(recoveredFromRuns.last?.turnID == "turn-actual") + #expect(recoveredFromRuns.last?.turnID.rawValue == "turn-actual") try #require(await waitForRunAttemptActivation(store: store, run: recoveredRun)) await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.run.turnID == "turn-recovered") + #expect(read.presentation.status == .succeeded) + #expect(read.core.attempt?.turnID.rawValue == "turn-recovered") } } @Test func networkRecoveryIgnoresStaleCompletionAfterRecoveredSubscriptionStarts() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -659,27 +813,28 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.run.turnID == "turn-2") + #expect(read.presentation.status == .succeeded) + #expect(read.core.attempt?.turnID.rawValue == "turn-2") } } @Test func networkRecoveryIgnoresStaleTerminalQueuedWhileRestarting() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -687,7 +842,7 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -706,33 +861,34 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.run.turnID == "turn-2") + #expect(read.presentation.status == .succeeded) + #expect(read.core.attempt?.turnID.rawValue == "turn-2") } } @Test func networkRecoveryResubscribesWhenInterruptedEventStreamFinished() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -750,27 +906,29 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) - #expect(read.core.run.turnID == "turn-2") + #expect(read.presentation.status == .succeeded) + #expect(read.core.attempt?.turnID.rawValue == "turn-2") } } @Test func cancellationWhileRecoveryRestartIsInFlightStopsRecoveredRun() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) + await backend.setDiscardedRestartAttempts([initialRun, recoveredRun]) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -778,7 +936,7 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -791,13 +949,16 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.satisfied()) try await backend.waitForRestartPreparedReview(timeout: .seconds(2)) - let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) #expect(cancel.cancelled) await recoverGate.open() let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.run.turnID == "turn-1") + #expect(read.presentation.status == .cancelled) + #expect(read.core.attempt?.turnID.rawValue == "turn-1") let commands = await backend.recordedCommands() #expect( @@ -813,33 +974,35 @@ struct CodexReviewStoreCommandTests { .init(message: "Stop") ))) #expect(commands.contains(.cleanupReview(recoveredRun))) + #expect(commands.filter { $0 == .cleanupReview(initialRun) }.count == 1) } } - @Test func runtimeStopWhileRecoveryRestartIsInFlightDetachesAndStopsRecoveredRun() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + @Test func runtimeStopJoinsInFlightRestartAndCleansLateRecoveredRun() async throws { + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let recoveredRun = CodexReviewBackendModel.Review.Run( + let recoveredRun = makeAttempt( attemptID: "attempt-recovered", - threadID: "thread-1", + sourceThreadID: "thread-1", turnID: "turn-2", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let recoverGate = AsyncGate() - await backend.holdRestartPreparedReview(with: recoverGate) + await backend.holdRestartPreparedReviewIgnoringCancellation(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) let recorder = RuntimeStopCleanupRequestRecorder() try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -855,28 +1018,29 @@ struct CodexReviewStoreCommandTests { let cleanupTask = Task { @MainActor in await store.cleanupActiveReviewsForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - workerDrainTimeout: .seconds(2) + reason: .system(message: "Review runtime stopped.") ) { request in await recorder.record(request) return true } } try #require( - await waitUntil { - let state = store.runtimeReviewRunState(runID: "run-1") - return state.hasActiveWorker == false && state.activeRun == nil - }) + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.cancelled, runID: "run-1") != nil + ) + #expect(store.runtimeReviewRunState(runID: makeRunID("run-1")).hasActiveWorker) await recoverGate.open() let cleanup = await cleanupTask.value let read = try await result - let request = try #require(await recorder.onlyRequest()) + let requests = await recorder.recordedRequests() #expect(cleanup.didComplete) - #expect(request.recoveryWaitingRuns == [initialRun]) - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycle.cancellation?.message == "Review runtime stopped.") - #expect(read.core.run.turnID == "turn-1") + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.recoveryWaitingAttempts.isEmpty }) + #expect(read.presentation.status == .cancelled) + #expect(read.core.cancellation?.message == "Review runtime stopped.") + #expect(read.core.attempt?.turnID.rawValue == "turn-1") + #expect(store.runtimeReviewRunState(runID: makeRunID("run-1")).hasActiveWorker == false) let commands = await backend.recordedCommands() #expect( commands.contains( @@ -889,19 +1053,20 @@ struct CodexReviewStoreCommandTests { } @Test func cancellationAfterRecoveryEventStreamFinishesWakesWorker() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let running = store.startReview( @@ -915,68 +1080,32 @@ struct CodexReviewStoreCommandTests { _ = try await running await backend.finishEvents(for: initialRun) - let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) let cleanedUp = await waitUntil { - let runtimeState = store.runtimeReviewRunState(runID: "run-1") - return runtimeState.hasActiveWorker == false && runtimeState.activeRun == nil + let runtimeState = store.runtimeReviewRunState(runID: makeRunID("run-1")) + return runtimeState.hasActiveWorker == false && runtimeState.activeAttempt == nil } - let read = try store.readReview(runID: "run-1") + let read = try store.readReview(runID: makeRunID("run-1")) #expect(cancel.cancelled) #expect(cleanedUp) - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycle.cancellation?.message == "Stop") - } - } - - @Test func runtimeStopLocalCancellationDetachesWorker() async throws { - let run = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", - turnID: "turn-1", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ) - let backend = FakeCodexReviewBackend(nextRun: run) - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "run-1" }) - ) - try await withStoreCommandTestCleanup(backend: backend, store: store) { - async let running = store.startReview( - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - waitTimeout: .milliseconds(20) - ) - _ = try await running - - let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - cancelWorkers: false - ) - let cancelled = try store.readReview(runID: "run-1") - - #expect(locallyCancelledReviewRunIDs == ["run-1"]) - #expect(cancelled.core.lifecycle.status == .cancelled) - let runtimeStateBeforeDetach = store.runtimeReviewRunState(runID: "run-1") - #expect(runtimeStateBeforeDetach.hasActiveWorker) - #expect(runtimeStateBeforeDetach.activeRun == run) - - store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - - let runtimeStateAfterDetach = store.runtimeReviewRunState(runID: "run-1") - #expect(runtimeStateAfterDetach.hasActiveWorker == false) - #expect(runtimeStateAfterDetach.activeRun == nil) + #expect(read.presentation.status == .cancelled) + #expect(read.core.cancellation?.message == "Stop") } } @Test func stopInterruptsActiveReviewBeforeMarkingRunStopped() async throws { - let run = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let run = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: run) + let backend = FakeCodexReviewBackend(plannedAttempt: run) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -996,75 +1125,36 @@ struct CodexReviewStoreCommandTests { await store.stop() } try await backend.waitForInterruptReview(timeout: .seconds(2)) - let inFlight = try store.readReview(runID: "run-1") + let inFlight = try store.readReview(runID: makeRunID("run-1")) - #expect(inFlight.core.lifecycle.status == .running) + #expect(inFlight.presentation.status == .running) await interruptGate.open() await stopTask.value - let stopped = try store.readReview(runID: "run-1") let commands = await backend.recordedCommands() #expect(commands.contains(.interruptReview(run, .init(message: "Review runtime stopped.")))) - #expect(stopped.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(runID: "run-1") - #expect(runtimeState.activeRun == nil) - #expect(runtimeState.hasActiveWorker == false) - } - } - - @Test func runtimeStopDetachesNetworkRecoveryWaitingWorker() async throws { - let run = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", - turnID: "turn-1", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ) - let backend = FakeCodexReviewBackend(nextRun: run) - let networkMonitor = ManualCodexReviewNetworkMonitor() - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "run-1" }), - networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) - ) - try await withStoreCommandTestCleanup(backend: backend, store: store) { - async let running = store.startReview( - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - waitTimeout: .milliseconds(20) - ) - - networkMonitor.yield(.init(status: .unsatisfied)) - try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - _ = try await running - - let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - cancelWorkers: false - ) - store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - - let runtimeState = store.runtimeReviewRunState(runID: "run-1") + #expect(store.reviewRun(id: makeRunID("run-1")) == nil) + let runtimeState = store.runtimeReviewRunState(runID: makeRunID("run-1")) + #expect(runtimeState.activeAttempt == nil) #expect(runtimeState.hasActiveWorker == false) - #expect(runtimeState.activeRun == nil) - #expect(runtimeState.isWaitingForNetworkRecovery == false) } } @Test func runtimeStopCleanupHandsRecoveryWaitingRunsToBackendCleanup() async throws { - let run = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let run = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: run) + let backend = FakeCodexReviewBackend(plannedAttempt: run) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) let recorder = RuntimeStopCleanupRequestRecorder() try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -1079,106 +1169,41 @@ struct CodexReviewStoreCommandTests { _ = try await running let result = await store.cleanupActiveReviewsForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - workerDrainTimeout: .seconds(2) + reason: .system(message: "Review runtime stopped.") ) { request in await recorder.record(request) return true } - let request = try #require(await recorder.onlyRequest()) - let read = try store.readReview(runID: "run-1") + let requests = await recorder.recordedRequests() + let read = try store.readReview(runID: makeRunID("run-1")) #expect(result.didComplete) - #expect(request.reason.message == "Review runtime stopped.") - #expect(request.recoveryWaitingRuns == [run]) - #expect(read.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(runID: "run-1") + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.reason.message == "Review runtime stopped." }) + #expect(requests.allSatisfy { $0.recoveryWaitingAttempts == [run] }) + #expect(read.presentation.status == .cancelled) + let runtimeState = store.runtimeReviewRunState(runID: makeRunID("run-1")) #expect(runtimeState.hasActiveWorker == false) - #expect(runtimeState.activeRun == nil) + #expect(runtimeState.activeAttempt == nil) #expect(runtimeState.isWaitingForNetworkRecovery == false) } } - @Test func runtimeStopCanDrainDetachedWorkerCleanup() async throws { - let run = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", - turnID: "turn-1", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ) - let backend = FakeCodexReviewBackend(nextRun: run) - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "run-1" }) - ) - try await withStoreCommandTestCleanup(backend: backend, store: store) { - async let running = store.startReview( - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")), - waitTimeout: .milliseconds(20) - ) - _ = try await running - - let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - cancelWorkers: false - ) - store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - - #expect(await store.drainRuntimeStopDetachedReviewWorkers(timeout: .seconds(2))) - #expect(store.runtimeReviewRunState(runID: "run-1").hasDetachedWorker == false) - #expect(await backend.recordedCommands().contains(.cleanupReview(run))) - } - } - - @Test func runtimeStopDetachLetsStartReviewReturnWhenBackendStartIsStuck() async throws { - let backend = FakeCodexReviewBackend() - let startReviewGate = AsyncGate() - await backend.holdStartReview(with: startReviewGate) - let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "run-1" }) - ) - try await withStoreCommandTestCleanup(backend: backend, store: store) { - let running = Task { @MainActor in - try await store.startReview( - sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .baseBranch("main")) - ) - } - try await backend.waitForStartReview(timeout: .seconds(2)) - - let locallyCancelledReviewRunIDs = store.cancelActiveReviewsLocallyForRuntimeStop( - reason: .system(message: "Review runtime stopped."), - cancelWorkers: false - ) - store.cancelAndDetachReviewWorkersForRuntimeStop(runIDs: locallyCancelledReviewRunIDs) - let resultBeforeStartReviewUnblocked = try await waitForTaskValue(running, timeout: .seconds(1)) - await startReviewGate.open() - let result = try #require(resultBeforeStartReviewUnblocked) - - #expect(locallyCancelledReviewRunIDs == ["run-1"]) - #expect(result.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(runID: "run-1") - #expect(runtimeState.hasActiveWorker == false) - #expect(runtimeState.activeRun == nil) - } - } - @Test func cancellationDuringNetworkRecoveryStopsWhenEventStreamFinishes() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", + let initialRun = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1", + activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }) + networkRecoveryPolicy: .init(clock: testReviewWorkerClock { _ in }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1188,24 +1213,32 @@ struct CodexReviewStoreCommandTests { networkMonitor.yield(.init(status: .unsatisfied)) try await backend.waitForPrepareReviewRestart(timeout: .seconds(2)) - _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) await backend.finishEvents(for: initialRun) let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycle.cancellation?.message == "Stop") + #expect(read.presentation.status == .cancelled) + #expect(read.core.cancellation?.message == "Stop") } } @Test func userCancellationWinsOverPendingNetworkRecovery() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "cancel-pending-recovery") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let networkMonitor = ManualCodexReviewNetworkMonitor() let debounceGate = AsyncGate() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in await debounceGate.wait() }) + networkRecoveryPolicy: .init( + clock: testReviewWorkerClock { _ in + try? await debounceGate.wait() + } + ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1215,12 +1248,15 @@ struct CodexReviewStoreCommandTests { try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) networkMonitor.yield(.init(status: .unsatisfied)) - _ = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + _ = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) await debounceGate.open() - await backend.yield(.cancelled("Stop")) + await backend.yield(.cancelled("Stop"), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .cancelled) + #expect(read.presentation.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains { command in @@ -1242,7 +1278,8 @@ struct CodexReviewStoreCommandTests { } @Test func sessionScopedCancelRejectsRunFromDifferentSession() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "session-scoped-cancel") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1252,17 +1289,21 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) await #expect(throws: (any Error).self) { try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), sessionID: "session-2", cancellation: .mcpClient(message: "Stop") ) } - #expect(try store.readReview(runID: "run-1").cancellable) + #expect(try store.readReview(runID: makeRunID("run-1")).cancellable) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) _ = try await result let commands = await backend.recordedCommands() @@ -1277,7 +1318,8 @@ struct CodexReviewStoreCommandTests { } @Test func cancelledReviewStaysCancelledWhenStreamClosesWithError() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "cancelled-stream-close") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1289,19 +1331,20 @@ struct CodexReviewStoreCommandTests { ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) _ = try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) - await backend.finishEvents(throwing: StreamClosedError()) + await backend.finishEvents(throwing: StreamClosedError(), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycleMessage == "Stop") + #expect(read.presentation.status == .cancelled) + #expect(read.presentation.lifecycle.message == "Stop") } } @Test func failedReviewDoesNotRequireReviewText() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "failed-no-output") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1312,55 +1355,124 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) - await backend.finishEvents(throwing: StreamClosedError()) + await backend.finishEvents(throwing: StreamClosedError(), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .failed) + #expect(read.presentation.status == .failed) } } - @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() async throws { - let initialRun = CodexReviewBackendModel.Review.Run( - threadID: "thread-1", - turnID: "turn-1", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ) - let recoveredRun = CodexReviewBackendModel.Review.Run( - attemptID: "attempt-recovered", - threadID: "thread-1", - turnID: "turn-2", - reviewThreadID: "review-thread-1", - model: "gpt-5" - ) - let backend = FakeCodexReviewBackend(nextRun: initialRun) - await backend.setNextRecoveredRun(recoveredRun) - let networkMonitor = ManualCodexReviewNetworkMonitor() - let outageSleepStarted = AsyncGate() - let debounceGate = AsyncGate() + @Test func serverInterruptionWithoutPendingCancellationFailsReview() async throws { + let attempt = makeAttempt(fixtureID: "backend-interruption") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), - idGenerator: .init(next: { "run-1" }), - networkMonitor: networkMonitor, - networkRecoveryPolicy: .init( - outageDebounce: .seconds(10), - recoverySettle: .seconds(1), - sleep: { _ in - await outageSleepStarted.open() - await debounceGate.wait() - } - ) + idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) - try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) - networkMonitor.yield(.init(status: .unsatisfied)) - await outageSleepStarted.wait() - await backend.finishEvents(throwing: StreamClosedError(), for: initialRun) + await backend.yield(.interrupted(message: nil), for: attempt) + let read = try await result + + #expect(read.presentation.status == .failed) + #expect(read.core.failure?.message == "Review was interrupted by the backend.") + #expect(read.core.failure == .interruptedByBackend(message: nil)) + } + } + + @Test func typedTerminalFailureSurvivesStoreCommit() async throws { + let attempt = makeAttempt(fixtureID: "typed-terminal-failure") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + let failure = ReviewBackendFailure.invalidTerminalStatus( + rawStatus: "future-terminal", + turnID: makeTurnID("turn-1"), + turnFailure: .init( + message: "Future terminal failure", + code: .unknown(rawValue: "future_code"), + additionalDetails: "Preserve this detail" + ) + ) + + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let result = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")) + ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + + await backend.yield(.failed(failure), for: attempt) + let read = try await result + + #expect(read.presentation.status == .failed) + #expect(read.core.failure == failure) + #expect( + read.core.failure?.message + == "Review ended with invalid terminal status future-terminal." + ) + } + } + + @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() async throws { + let initialRun = makeAttempt( + attemptID: "attempt-initial", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1", + model: "gpt-5" + ) + let recoveredRun = makeAttempt( + attemptID: "attempt-recovered", + sourceThreadID: "thread-1", + turnID: "turn-2", + activeTurnThreadID: "review-thread-1", + model: "gpt-5" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) + let networkMonitor = ManualCodexReviewNetworkMonitor() + let outageSleepStarted = AsyncGate() + let debounceGate = AsyncGate() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + networkMonitor: networkMonitor, + networkRecoveryPolicy: .init( + outageDebounce: .seconds(10), + recoverySettle: .seconds(1), + clock: testReviewWorkerClock { _ in + await outageSleepStarted.open() + try? await debounceGate.wait() + } + ) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let result = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .baseBranch("main")) + ) + try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) + + networkMonitor.yield(.init(status: .unsatisfied)) + try? await outageSleepStarted.wait() + await backend.yield( + .failed(.connectionTerminated(.closed)), + for: initialRun + ) let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store) @@ -1376,12 +1488,18 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found."), for: recoveredRun) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) + #expect(read.presentation.status == .succeeded) } } - @Test func reviewStartCancellationInterruptsBackendRun() async throws { - let backend = FakeCodexReviewBackend() + @Test func terminalObservationCancellationWithoutParentFailsLoud() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1392,22 +1510,38 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) - await backend.finishEvents(throwing: CancellationError()) + await backend.finishEvents(throwing: CancellationError(), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .cancelled) + #expect(read.presentation.status == .failed) + #expect( + read.core.failure == .protocolViolation( + message: "Review terminal observation was cancelled without parent cancellation." + ) + ) let commands = await backend.recordedCommands() #expect( commands.contains( .interruptReview( - .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ), .init(message: "Cancellation requested.") - ))) + )) == false) } } @Test func reviewStartTaskCancellationInterruptsBackendRun() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1422,19 +1556,25 @@ struct CodexReviewStoreCommandTests { task.cancel() let read = try await task.value - #expect(read.core.lifecycle.status == .cancelled) + #expect(read.presentation.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains( .interruptReview( - .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ), .init(message: "Cancellation requested.") ))) } } @Test func failedInterruptClearsCancellationRequestState() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "failed-interrupt") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) await backend.failInterrupts(message: "Interrupt failed") let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1446,25 +1586,157 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .baseBranch("main")) ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) - await #expect(throws: FakeCodexReviewBackendError.self) { + await #expect(throws: ReviewBackendFailure.self) { try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) } - let readAfterFailure = try store.readReview(runID: "run-1") + let readAfterFailure = try store.readReview(runID: makeRunID("run-1")) #expect(readAfterFailure.cancellable) - #expect(readAfterFailure.core.lifecycle.cancellation == nil) - #expect(readAfterFailure.core.lifecycleMessage == "Failed to cancel review: Interrupt failed") + #expect(readAfterFailure.core.cancellation == nil) + #expect(readAfterFailure.presentation.lifecycle.message == "Review started.") - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) _ = try await result } } + @Test func concurrentCancellationCallersJoinOneAcceptedOperation() async throws { + let attempt = makeAttempt(fixtureID: "concurrent-cancellation") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let interruptGate = AsyncGate() + await backend.holdInterruptReview(with: interruptGate) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let start = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + + async let first = store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) + try await backend.waitForInterruptReview(timeout: .seconds(2)) + async let second = store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop again") + ) + await interruptGate.open() + + let firstOutcome = try await first + let secondOutcome = try await second + let final = try await start + let interrupts = await backend.recordedCommands().filter { + if case .interruptReview = $0 { true } else { false } + } + + #expect(interrupts.count == 1) + #expect(firstOutcome.presentation.status == .cancelled) + #expect(secondOutcome.presentation.status == .cancelled) + #expect(final.core.cancellation?.message == "Stop") + } + } + + @Test func cancellationCallerCanLeaveWithoutCancellingAcceptedOperation() async throws { + let attempt = makeAttempt(fixtureID: "departing-cancellation-caller") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let interruptGate = AsyncGate() + await backend.holdInterruptReview(with: interruptGate) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let start = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + + let firstCaller = Task { @MainActor in + try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) + } + try await backend.waitForInterruptReview(timeout: .seconds(2)) + firstCaller.cancel() + await #expect(throws: CancellationError.self) { + try await firstCaller.value + } + + async let joiningCaller = store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Ignored replacement") + ) + await interruptGate.open() + let joined = try await joiningCaller + let final = try await start + let interrupts = await backend.recordedCommands().filter { + if case .interruptReview = $0 { true } else { false } + } + + #expect(interrupts.count == 1) + #expect(joined.presentation.status == .cancelled) + #expect(final.core.cancellation?.message == "Stop") + } + } + + @Test func terminalCancellationWinnerIgnoresLateInterruptFailure() async throws { + let attempt = makeAttempt(fixtureID: "terminal-cancellation-winner") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let interruptGate = AsyncGate() + await backend.holdInterruptReview(with: interruptGate) + await backend.failInterrupts(message: "Late interrupt failure") + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let start = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + async let cancel = store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) + try await backend.waitForInterruptReview(timeout: .seconds(2)) + await backend.yield(.completed(finalReview: "Terminal won"), for: attempt) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.cancelled, runID: "run-1") != nil + ) + await interruptGate.open() + + let outcome = try await cancel + let final = try await start + #expect(outcome.presentation.status == .cancelled) + #expect(final.presentation.status == .cancelled) + #expect(final.core.cancellation?.message == "Stop") + } + } + @Test func cancelledReviewIgnoresBufferedTerminalEvents() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "cancelled-buffered-terminal") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1476,19 +1748,20 @@ struct CodexReviewStoreCommandTests { ) try #require(await StoreSnapshotProbe(store: store).waitUntilRunStatus(.running, runID: "run-1") != nil) _ = try await store.cancelReview( - runID: "run-1", + runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycleMessage == "Stop") + #expect(read.presentation.status == .cancelled) + #expect(read.presentation.lifecycle.message == "Stop") } } @Test func terminalEventDuringPendingCancellationKeepsCancelledState() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "terminal-during-cancellation") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -1500,22 +1773,35 @@ struct CodexReviewStoreCommandTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - async let cancel = store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + async let cancel = store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) try await backend.waitForInterruptReview(timeout: .seconds(2)) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.interrupted(message: nil), for: attempt) await interruptGate.open() _ = try await cancel let read = try await result - #expect(read.core.lifecycle.status == .cancelled) - #expect(read.core.lifecycleMessage == "Stop") + #expect(read.presentation.status == .cancelled) + #expect(read.presentation.lifecycle.message == "Stop") } } @Test func cancelDuringReviewStartupInterruptsAfterRunBecomesAvailable() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let gate = AsyncGate() - await backend.holdStartReview(with: gate) + await backend.holdStartReviewIgnoringCancellation(with: gate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1526,40 +1812,101 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) try await backend.waitForStartReview(timeout: .seconds(2)) - let cancel = try await store.cancelReview(runID: "run-1", cancellation: .mcpClient(message: "Stop")) + let cancel = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) let cancelledDuringStartup = try #require(store.reviewRuns.first) - #expect(cancel.core.lifecycle.status == .cancelled) - #expect(cancelledDuringStartup.core.lifecycle.status == .cancelled) + #expect(cancel.presentation.status == .cancelled) + #expect(cancelledDuringStartup.presentation.status == .cancelled) await gate.open() let read = try await result #expect(cancel.cancelled) - #expect(read.core.lifecycle.status == .cancelled) + #expect(read.presentation.status == .cancelled) let commands = await backend.recordedCommands() #expect( commands.contains( .interruptReview( - .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "review-thread-1"), + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ), .init(message: "Stop") ))) #expect( commands.contains( .cleanupReview( - .init( - threadID: "thread-1", + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", turnID: "turn-1", - reviewThreadID: "review-thread-1" + activeTurnThreadID: "review-thread-1" )))) + #expect( + commands.contains( + .cleanupRetainedReviews([ + makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + ]))) + #expect( + try await store.reviewThreadRetentionRegistry + .snapshotForTesting().entries.isEmpty + ) } } - @Test func closedSessionRejectsNewReviews() async throws { + @Test func cancelDuringReviewStartupRetainsFailedUnpublishedCleanup() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let gate = AsyncGate() + await backend.holdStartReviewIgnoringCancellation(with: gate) + await backend.failRetainedCleanup(message: "Delete failed") + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let result = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + try await backend.waitForStartReview(timeout: .seconds(2)) + _ = try await store.cancelReview( + runID: makeRunID("run-1"), + cancellation: .mcpClient(message: "Stop") + ) + + await gate.open() + let read = try await result + let entries = try await store.reviewThreadRetentionRegistry + .snapshotForTesting().entries + + #expect(read.presentation.status == .cancelled) + #expect(entries.count == 1) + #expect(entries.first?.runID == makeRunID("run-1")) + #expect(entries.first?.attempts == [attempt]) + } + } + + @Test func closedSessionRejectsNewReviews() async { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) await withStoreCommandTestCleanup(backend: backend, store: store) { - await store.closeSession("session-1") + _ = await store.closeSession("session-1") await #expect(throws: (any Error).self) { try await store.startReview( @@ -1570,38 +1917,175 @@ struct CodexReviewStoreCommandTests { } } - @Test func closeActiveReviewSessionsCancelsRunsWithoutClosingMCPServerSession() async throws { - let backend = FakeCodexReviewBackend() + @Test func closeSessionJoinsLateStartupCleanupBeforeReturningEvidence() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let startGate = AsyncGate() + await backend.holdStartReviewIgnoringCancellation(with: startGate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { - let running = ReviewRunRecord.makeForTesting( - id: "running-run", + async let started = store.startReview( sessionID: "session-1", - cwd: "/tmp/project", - targetSummary: "Running", - status: .running, - summary: "Running" + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - store.loadForTesting( - serverState: .running, - reviewRuns: [running] + try await backend.waitForStartReview(timeout: .seconds(2)) + + async let close = store.closeSession("session-1") + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.cancelled, runID: "run-1") != nil ) + await startGate.open() - await store.closeActiveReviewSessions(reason: .system(message: "Account switched.")) + let evidence = await close + let final = try await started + #expect(evidence.terminalAndDrainedRunIDs == [makeRunID("run-1")]) + #expect(evidence.failedRunIDs.isEmpty) + #expect(final.presentation.status == .cancelled) + #expect(store.runtimeReviewRunState(runID: makeRunID("run-1")).hasActiveWorker == false) + #expect(await backend.recordedCommands().contains(.cleanupReview(attempt))) - #expect(running.core.lifecycle.status == .cancelled) - async let result = store.startReview( + #expect(store.closedSessions.contains("session-1")) + store.releaseClosedSession("session-1") + #expect(store.closedSessions.contains("session-1") == false) + } + } + + @Test func closeSessionStartsEveryMemberCancellationBeforeJoiningInterrupts() async throws { + let firstAttempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let secondAttempt = makeAttempt( + attemptID: "attempt-2", + sourceThreadID: "thread-2", + turnID: "turn-2", + activeTurnThreadID: "review-thread-2" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: firstAttempt) + let interruptGate = AsyncGate() + await backend.holdInterruptReview(with: interruptGate) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + let first = try await store.startReview( sessionID: "session-1", - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) ) - await backend.yield(.completed(finalReview: "No issues found.")) - let read = try await result + await backend.planNextAttempt(secondAttempt) + let second = try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) + ) + + let close = Task { @MainActor in + await store.closeSession("session-1") + } + try await backend.waitForInterruptReviews(count: 2, timeout: .seconds(2)) - #expect(read.runID == "run-1") - #expect(read.core.lifecycle.status == .succeeded) + let commands = await backend.recordedCommands() + let interrupts = commands.filter { + if case .interruptReview = $0 { true } else { false } + } + #expect(interrupts.count == 2) + + await interruptGate.open() + let evidence = await close.value + #expect(evidence.terminalAndDrainedRunIDs == [first.runID, second.runID]) + #expect(evidence.failedRunIDs.isEmpty) + } + } + + @Test func runningWorkerDoesNotRetainDroppedStore() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + weak var weakStore: CodexReviewStore? + var store: CodexReviewStore? = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + _ = try await store?.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) + ) + weakStore = store + + store = nil + + #expect(weakStore == nil) + try await backend.waitForCleanupReview(attempt, timeout: .seconds(2)) + await backend.finishEventMailboxes() + } + + @Test func closeActiveReviewSessionsCancelsRunsWithoutClosingMCPServerSession() async throws { + let attempt = makeAttempt(fixtureID: "close-active-sessions") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + async let started = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) + ) + _ = try await started + + let didDrain = await store.closeActiveReviewSessions( + reason: .system(message: "Account switched.") + ) + + #expect(didDrain) + #expect(try store.readReview(runID: makeRunID("run-1")).presentation.status == .cancelled) + #expect(store.closedSessions.contains("session-1") == false) + } + } + + @Test func closeActiveReviewSessionsJoinsCancelledStartupWorker() async throws { + let attempt = makeAttempt(fixtureID: "close-startup-worker") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let startGate = AsyncGate() + await backend.holdStartReview(with: startGate) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + try await withStoreCommandTestCleanup(backend: backend, store: store) { + let running = Task { @MainActor in + try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + try await backend.waitForStartReview(timeout: .seconds(2)) + let didDrain = await store.closeActiveReviewSessions( + reason: .system(message: "Account switched.") + ) + + #expect(didDrain) + await startGate.open() + let result = try await running.value + #expect(result.presentation.status == .cancelled) } } @@ -1724,26 +2208,18 @@ struct CodexReviewStoreCommandTests { #expect(store.canPerformPrimaryAuthenticationAction) } - @Test func primaryAuthenticationActionRestartsRecoverableRuntimeBeforeLogin() async { + @Test func primaryAuthenticationActionRestartsRecoverableRuntimeBeforeLogin() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - await withStoreCommandTestCleanup(backend: backend, store: store) { + try await withStoreCommandTestCleanup(backend: backend, store: store) { store.loadForTesting(serverState: .failed("Runtime failed."), authPhase: .signedOut) - await store.performPrimaryAuthenticationAction() + try await store.performPrimaryAuthenticationAction() #expect(store.serverState == .running) #expect(store.auth.isAuthenticating) - let commands = await backend.recordedCommands() - #expect( - commands.contains { command in - if case .startLogin = command { - return true - } - return false - }) } } } @@ -1786,11 +2262,60 @@ private func waitUntil( @MainActor private func waitForRunAttemptActivation( store: CodexReviewStore, - run: CodexReviewBackendModel.Review.Run, + run: ReviewAttempt, timeout: Duration = .seconds(2) ) async -> Bool { await StoreSnapshotProbe(store: store) - .waitUntilRunAttempt(run.attemptID, timeout: timeout) != nil + .waitUntilRunAttempt(run.attemptID.rawValue, timeout: timeout) != nil +} + +private func makeAttempt( + attemptID: String, + sourceThreadID: String, + turnID: String, + activeTurnThreadID: String, + model: String? = nil +) -> ReviewAttempt { + makeReviewAttemptForTesting( + attemptID: attemptID, + sourceThreadID: sourceThreadID, + activeTurnThreadID: activeTurnThreadID, + turnID: turnID, + model: model + ) +} + +private func makeAttempt(fixtureID: String) -> ReviewAttempt { + makeReviewAttemptForTesting( + attemptID: "attempt-\(fixtureID)", + sourceThreadID: "source-\(fixtureID)", + activeTurnThreadID: "active-\(fixtureID)", + turnID: "turn-\(fixtureID)" + ) +} + +private func makeTurnID(_ rawValue: String) -> ReviewTurnID { + do { + return try ReviewTurnID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review turn fixture: \(error)") + } +} + +private func makeRunID(_ rawValue: String) -> ReviewRunID { + do { + return try ReviewRunID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review run fixture: \(error)") + } +} + +private func makeThreadID(_ rawValue: String) -> ReviewThreadID { + do { + return try ReviewThreadID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review thread fixture: \(error)") + } } private func waitForTaskValue( @@ -1845,8 +2370,8 @@ private actor RuntimeStopCleanupRequestRecorder { requests.append(request) } - func onlyRequest() -> CodexReviewRuntimeStopReviewCleanupRequest? { - requests.count == 1 ? requests[0] : nil + func recordedRequests() -> [CodexReviewRuntimeStopReviewCleanupRequest] { + requests } } @@ -1864,11 +2389,21 @@ private actor ControlledTestSleeper { func sleep() async { if shouldBlock { - await gate.wait() + try? await gate.wait() } } } +private func testReviewWorkerClock( + sleep: @escaping @Sendable (Duration) async throws -> Void +) -> ReviewWorkerClock { + let now = ContinuousClock().now + return .init( + now: { now }, + sleep: sleep + ) +} + private final class MutableTestClock: @unchecked Sendable { var current: Date diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 16887ed3..889785ba 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift @@ -166,6 +166,10 @@ struct CodexReviewStoreRateLimitAutoRefreshTests { let account = makeAccount(lastFetchAt: now) let runningRun = ReviewRunRecord.makeForTesting( targetSummary: "Review changes", + attemptID: "attempt-running", + threadID: "thread-running", + reviewThreadID: "review-thread-running", + turnID: "turn-running", status: .running, startedAt: now, summary: "Running." @@ -389,11 +393,11 @@ private final class BlockingRateLimitRefreshBackend: PreviewCodexReviewStoreBack ) async { refreshedAccountKeys.append(accountKey) await startedGate.open() - await releaseGate.wait() + try? await releaseGate.wait() } func waitUntilRefreshStarts() async { - await startedGate.wait() + try? await startedGate.wait() } func releaseRefresh() async { diff --git a/Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift b/Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift new file mode 100644 index 00000000..3edb2502 --- /dev/null +++ b/Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift @@ -0,0 +1,95 @@ +import Testing +@testable import CodexReviewKit +import CodexReviewTesting + +@Suite("Fake review backend attempt planning", .serialized) +struct FakeCodexReviewBackendAttemptTests { + @Test func startRejectsMissingAttemptPlanAndConsumesEachPlanOnce() async throws { + let request = try makeStartRequest(runID: "run-initial") + let backend = FakeCodexReviewBackend() + + await #expect(throws: FakeCodexReviewBackendError.self) { + _ = try await backend.startReview(request) + } + + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-initial", + sourceThreadID: "thread-initial", + activeTurnThreadID: "review-thread-initial", + turnID: "turn-initial" + ) + await backend.planNextAttempt(attempt) + let started = try await backend.startReview(request) + #expect(started.attempt == attempt) + + await #expect(throws: FakeCodexReviewBackendError.self) { + _ = try await backend.startReview(try makeStartRequest(runID: "run-unplanned")) + } + await backend.finishEventMailboxes() + } + + @Test func restartRejectsMissingAttemptPlanWithoutFabricatingIdentity() async throws { + let initialAttempt = makeReviewAttemptForTesting( + attemptID: "attempt-initial", + sourceThreadID: "thread-initial", + activeTurnThreadID: "review-thread-initial", + turnID: "turn-initial" + ) + let recoveredAttempt = makeReviewAttemptForTesting( + attemptID: "attempt-recovered", + sourceThreadID: "thread-initial", + activeTurnThreadID: "review-thread-initial", + turnID: "turn-recovered" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: initialAttempt) + let request = try makeStartRequest(runID: "run-restart") + let started = try await backend.startReview(request) + let token = try await backend.prepareReviewRestart(started.attempt) + + await #expect(throws: FakeCodexReviewBackendError.self) { + _ = try await backend.restartPreparedReview(token, request: request) + } + + await backend.planNextRecoveredAttempt(recoveredAttempt) + let restarted = try await backend.restartPreparedReview(token, request: request) + #expect(restarted.attempt == recoveredAttempt) + await backend.finishEventMailboxes() + } + + @Test func cancelledStartGateDoesNotConsumeItsAttemptPlan() async throws { + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-retry", + sourceThreadID: "thread-retry", + activeTurnThreadID: "review-thread-retry", + turnID: "turn-retry" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let gate = AsyncGate() + await backend.holdStartReview(with: gate) + let request = try makeStartRequest(runID: "run-retry") + let cancelledStart = Task { + try await backend.startReview(request) + } + try await backend.waitForStartReview(timeout: .seconds(2)) + + cancelledStart.cancel() + await #expect(throws: CancellationError.self) { + _ = try await cancelledStart.value + } + + await gate.open() + let retried = try await backend.startReview(request) + #expect(retried.attempt == attempt) + await backend.finishEventMailboxes() + } +} + +private func makeStartRequest( + runID: String +) throws -> CodexReviewBackendModel.Review.Start { + try .init( + runID: ReviewRunID(validating: runID), + sessionID: "session-attempt-planning", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) +} diff --git a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index ea05c236..d33bf744 100644 --- a/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift +++ b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift @@ -5,34 +5,106 @@ import Testing @Suite("Review run core") @MainActor struct ReviewRunCoreTests { - @Test func coreKeepsLifecycleMessageOnly() { - let succeeded = ReviewRunCore( - lifecycle: .init(status: .succeeded), - lifecycleMessage: "Succeeded." + @Test func coreOwnsTypedTerminalFactsWithoutStoredPresentationText() throws { + let attempt = makeAttempt() + let startedAt = Date(timeIntervalSince1970: 10) + let endedAt = Date(timeIntervalSince1970: 20) + let succeeded = ReviewRunCore.succeeded( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt ) - #expect(succeeded.lifecycle.status == .succeeded) - #expect(succeeded.lifecycleMessage == "Succeeded.") - - let failed = ReviewRunCore( - lifecycle: .init( - status: .failed, - errorMessage: "Backend failed." - ), - lifecycleMessage: "Failed." + #expect(succeeded.status == .succeeded) + #expect(succeeded.failure == nil) + + let failed = ReviewRunCore.failed( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + failure: .protocolViolation(message: "Backend failed.") + ) + #expect(failed.status == .failed) + #expect(failed.failure?.message == "Backend failed.") + + let cancelled = ReviewRunCore.cancelled( + attempt: attempt, + startedAt: startedAt, + endedAt: endedAt, + cancellation: .mcpClient(message: "Session closed.") ) - #expect(failed.lifecycle.status == .failed) - #expect(failed.lifecycle.errorMessage == "Backend failed.") - #expect(failed.lifecycleMessage == "Failed.") - - let cancelled = ReviewRunCore( - lifecycle: .init( - status: .cancelled, - cancellation: .mcpClient(message: "Session closed.") - ), - lifecycleMessage: "Cancelled." + #expect(cancelled.status == .cancelled) + #expect(cancelled.cancellation?.message == "Session closed.") + + let encoded = String(decoding: try JSONEncoder().encode(failed), as: UTF8.self) + #expect(encoded.contains("lifecycleMessage") == false) + #expect(encoded.contains("errorMessage") == false) + #expect(encoded.contains("finalReview") == false) + } + + @Test func attemptIdentityValidationRejectsEmptyAndWhitespaceValues() { + #expect(throws: ReviewIdentityValidationError.empty(field: "runID")) { + try ReviewRunID(validating: " \n ") + } + #expect(throws: ReviewIdentityValidationError.empty(field: "attemptID")) { + try ReviewAttemptID(validating: " \n ") + } + #expect(throws: ReviewIdentityValidationError.empty(field: "threadID")) { + try ReviewThreadID(validating: "") + } + #expect(throws: ReviewIdentityValidationError.empty(field: "turnID")) { + try ReviewTurnID(validating: "\t") + } + } + + @Test func identityValidationPreservesAcceptedRawValue() throws { + let rawValue = " attempt-1 " + #expect(try ReviewAttemptID(validating: rawValue).rawValue == rawValue) + #expect(try ReviewRunID(validating: rawValue).rawValue == rawValue) + } + + @Test func identityDecodingUsesTheValidatingInitializer() { + let data = Data(#"" ""#.utf8) + #expect(throws: ReviewIdentityValidationError.empty(field: "runID")) { + try JSONDecoder().decode(ReviewRunID.self, from: data) + } + #expect(throws: ReviewIdentityValidationError.empty(field: "attemptID")) { + try JSONDecoder().decode(ReviewAttemptID.self, from: data) + } + } + + @Test func onlyPreAttemptStatesCanOmitAttemptIdentity() { + let endedAt = Date(timeIntervalSince1970: 20) + let queued = ReviewRunCore.queued + let startFailed = ReviewRunCore.startFailed( + endedAt: endedAt, + failure: .protocolViolation(message: "Failed.") + ) + let cancelledBeforeStart = ReviewRunCore.cancelledBeforeStart( + endedAt: endedAt, + cancellation: .system(message: "Cancelled.") + ) + let running = ReviewRunCore.running( + attempt: makeAttempt(), + startedAt: Date(timeIntervalSince1970: 10) + ) + + #expect(queued.attempt == nil) + #expect(startFailed.attempt == nil) + #expect(cancelledBeforeStart.attempt == nil) + #expect(running.attempt == makeAttempt()) + } +} + +private func makeAttempt() -> ReviewAttempt { + do { + return try ReviewAttempt( + validatingAttemptID: "attempt-1", + sourceThreadID: "thread-1", + activeTurnThreadID: "review-thread-1", + turnID: "turn-1", + model: "gpt-5" ) - #expect(cancelled.lifecycle.status == .cancelled) - #expect(cancelled.lifecycle.cancellation?.message == "Session closed.") - #expect(cancelled.lifecycleMessage == "Cancelled.") + } catch { + preconditionFailure("Invalid explicit review attempt fixture: \(error)") } } diff --git a/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift b/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift new file mode 100644 index 00000000..3e137f46 --- /dev/null +++ b/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift @@ -0,0 +1,536 @@ +import Foundation +import Testing +@_spi(Testing) @testable import CodexReviewKit +import CodexReviewTesting + +@Suite("Review thread retention", .serialized) +@MainActor +struct ReviewThreadRetentionRegistryTests { + @Test func claimPersistsAndMergesRestartIdentityBeforePublication() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let registry = ReviewThreadRetentionRegistry(journal: journal) + let runID = try ReviewRunID(validating: "run-1") + let scope = ReviewThreadRetentionScope( + codexHomePath: "/tmp/codex-home", + accountKey: "account@example.com" + ) + let initial = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "source-thread", + activeTurnThreadID: "review-thread-1", + turnID: "turn-1" + ) + let restarted = makeReviewAttemptForTesting( + attemptID: "attempt-2", + sourceThreadID: "source-thread", + activeTurnThreadID: "review-thread-2", + turnID: "turn-2" + ) + + try await registry.claim(initial, for: runID, scope: scope) + try await registry.claim(restarted, for: runID, scope: scope) + + let snapshot = try await registry.snapshotForTesting() + let entry = try #require(snapshot.entries.first) + #expect(snapshot.entries.count == 1) + #expect(entry.runID == runID) + #expect(entry.scope == scope) + #expect(entry.attempts == [initial, restarted]) + #expect(await registry.acceptance() == .accepting) + } + + @Test func repeatedDurableClaimDoesNotRequireAnotherJournalWrite() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let registry = ReviewThreadRetentionRegistry(journal: journal) + let runID = try ReviewRunID(validating: "run-1") + let scope = ReviewThreadRetentionScope( + codexHomePath: "/tmp/codex-home", + accountKey: "account@example.com" + ) + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "source-thread", + activeTurnThreadID: "review-thread", + turnID: "turn-1" + ) + + try await registry.claim(attempt, for: runID, scope: scope) + await journal.failReplacements("disk unavailable") + + try await registry.claim(attempt, for: runID, scope: scope) + + #expect(await registry.acceptance() == .accepting) + #expect(try await registry.snapshotForTesting().entries.first?.attempts == [attempt]) + } + + @Test func failedJournalAndCleanupRemainTypedQuarantineUntilDurableRetry() async throws { + let journal = ControlledReviewThreadRetentionJournal() + await journal.failReplacements("disk unavailable") + let registry = ReviewThreadRetentionRegistry(journal: journal) + let runID = try ReviewRunID(validating: "run-1") + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "source-thread", + activeTurnThreadID: "review-thread", + turnID: "turn-1" + ) + + await #expect(throws: ReviewThreadRetentionRegistryError.self) { + try await registry.claim( + attempt, + for: runID, + scope: .init(codexHomePath: "/tmp/codex-home", accountKey: nil) + ) + } + await registry.recordFailedClaimCleanup( + runID: runID, + journalFailure: "disk unavailable", + failedThreadIDs: [attempt.threadIdentity.activeTurnThreadID], + cleanupFailure: "thread delete failed" + ) + + let quarantined = try #require(await registry.acceptance().quarantines) + #expect(quarantined.count == 1) + #expect(quarantined[0].entry.attempts == [attempt]) + #expect(quarantined[0].journalFailure == "disk unavailable") + #expect(quarantined[0].cleanupFailure == "thread delete failed") + + await journal.failReplacements(nil) + #expect(await registry.retryQuarantinedJournalCommits() == .accepting) + #expect(try await registry.snapshotForTesting().entries.first?.attempts == [attempt]) + } + + @Test func fileJournalRejectsMalformedEntryWithoutInvokingRuntimePreconditions() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("review-retention-malformed-\(UUID().uuidString)", isDirectory: true) + let fileURL = directory.appendingPathComponent("journal.json") + defer { + try? FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let malformed: [String: Any] = [ + "version": 1, + "entries": [[ + "runID": "run-1", + "scope": ["codexHomePath": "/tmp/codex-home"], + "attempts": [], + ]], + ] + try JSONSerialization.data(withJSONObject: malformed).write(to: fileURL) + let journal = FileReviewThreadRetentionJournal(fileURL: fileURL) + + await #expect(throws: DecodingError.self) { + _ = try await journal.load() + } + } + + @Test func fileJournalRejectsConflictingDuplicateAttemptIdentity() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("review-retention-conflict-\(UUID().uuidString)", isDirectory: true) + let fileURL = directory.appendingPathComponent("journal.json") + defer { + try? FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let attempt: (String) -> [String: Any] = { turnID in + [ + "attemptID": "attempt-1", + "threadIdentity": [ + "sourceThreadID": "source-thread", + "activeTurnThreadID": "review-thread", + ], + "turnID": turnID, + ] + } + let malformed: [String: Any] = [ + "version": 1, + "entries": [[ + "runID": "run-1", + "scope": ["codexHomePath": "/tmp/codex-home"], + "attempts": [attempt("turn-1"), attempt("turn-2")], + ]], + ] + try JSONSerialization.data(withJSONObject: malformed).write(to: fileURL) + let journal = FileReviewThreadRetentionJournal(fileURL: fileURL) + + await #expect(throws: DecodingError.self) { + _ = try await journal.load() + } + } + + @Test func journalRemovalFailureKeepsDurableTombstoneForRetry() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let registry = ReviewThreadRetentionRegistry(journal: journal) + let runID = try ReviewRunID(validating: "run-1") + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "source-thread", + activeTurnThreadID: "review-thread", + turnID: "turn-1" + ) + try await registry.claim( + attempt, + for: runID, + scope: .init(codexHomePath: "/tmp/codex-home", accountKey: nil) + ) + + await journal.failReplacements("remove failed") + await registry.recordCleanupSucceeded(for: runID) + #expect(try await registry.snapshotForTesting().entries.map(\.runID) == [runID]) + #expect(try await journal.load().entries.map(\.runID) == [runID]) + + await journal.failReplacements(nil) + await registry.recordCleanupSucceeded(for: runID) + #expect(try await registry.snapshotForTesting().entries.isEmpty) + #expect(try await journal.load().entries.isEmpty) + } + + @Test func startupOrphanCleanupRemovesJournalWithoutRestoringVisibleRun() async throws { + let entry = makeRetentionEntry(runID: "orphan-run") + let journal = ControlledReviewThreadRetentionJournal( + snapshot: .init(entries: [entry]) + ) + let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + reviewThreadRetentionJournal: journal + ) + + #expect(await store.recoverOrphanedReviewThreads() == .recovered) + #expect(store.reviewRuns.isEmpty) + #expect(try await journal.load().entries.isEmpty) + #expect(await backend.recordedCommands().contains(.cleanupRetainedReviews(entry.attempts))) + } + + @Test func startupOrphanCleanupFailureKeepsTombstoneWithoutVisibleRun() async throws { + let entry = makeRetentionEntry(runID: "orphan-run") + let journal = ControlledReviewThreadRetentionJournal( + snapshot: .init(entries: [entry]) + ) + let backend = FakeCodexReviewBackend() + await backend.failRetainedCleanup(message: "delete failed") + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + reviewThreadRetentionJournal: journal + ) + + #expect(await store.recoverOrphanedReviewThreads() == .cleanupIncomplete) + #expect(store.reviewRuns.isEmpty) + let retained = try #require(try await journal.load().entries.first) + #expect(retained.runID == entry.runID) + #expect(retained.scope == entry.scope) + #expect(retained.attempts == entry.attempts) + #expect( + retained.additionalCleanupThreadIDs + == [entry.attempts[0].threadIdentity.activeTurnThreadID] + ) + } + + @Test func startupJournalLoadFailureIsTypedAndDoesNotPublishRuntimeRecovery() async { + let journal = ControlledReviewThreadRetentionJournal() + await journal.failLoads("journal corrupt") + let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + reviewThreadRetentionJournal: journal + ) + + #expect( + await store.recoverOrphanedReviewThreads() + == .journalUnavailable(message: "journal corrupt") + ) + #expect(store.reviewRuns.isEmpty) + } + + @Test func journalLoadFailureRejectsReviewBeforeStartingBackendWork() async throws { + let journal = ControlledReviewThreadRetentionJournal() + await journal.failLoads("journal corrupt") + let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + + do { + _ = try await store.beginReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + Issue.record("A review must not start while its retention journal is unavailable.") + } catch let failure as ReviewBackendFailure { + #expect(failure.isRetentionJournalFailure) + } + + #expect(store.reviewRuns.isEmpty) + #expect(await backend.recordedCommands().isEmpty) + #expect( + await store.reviewThreadRetentionRegistry.acceptance() + == .journalUnavailable(message: "journal corrupt") + ) + } + + @Test func storeDoesNotPublishAttemptWhoseJournalCommitFailed() async throws { + let journal = ControlledReviewThreadRetentionJournal() + await journal.failReplacements("disk unavailable") + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + try await withRetentionStoreCleanup(backend: backend, store: store) { + let result = try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + + #expect(result.presentation.status == .failed) + #expect(result.core.attempt == nil) + #expect(result.core.failure?.isRetentionJournalFailure == true) + let commands = await backend.recordedCommands() + #expect(commands.contains { command in + if case .cleanupRetainedReviews = command { true } else { false } + }) + #expect(await store.reviewThreadRetentionRegistry.acceptance() == .accepting) + } + } + + @Test func doubleFailureClosesAcceptanceGateUntilCleanupRecovery() async throws { + let journal = ControlledReviewThreadRetentionJournal() + await journal.failReplacements("disk unavailable") + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + await backend.failRetainedCleanup(message: "delete failed") + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + try await withRetentionStoreCleanup(backend: backend, store: store) { + let failed = try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + #expect(failed.core.failure?.isRetentionJournalFailure == true) + #expect(await store.reviewThreadRetentionRegistry.acceptance().isAccepting == false) + + await #expect(throws: ReviewBackendFailure.self) { + try await store.startReview( + sessionID: "session-2", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + } + + await backend.failRetainedCleanup(message: nil) + #expect(await store.retireReviewRunsForFinalStoreStop()) + #expect(store.reviewRuns.isEmpty) + #expect(await store.reviewThreadRetentionRegistry.acceptance() == .accepting) + } + } + + @Test func preservingRestartKeepsThreadsAndFinalStopRetiresThem() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + try await withRetentionStoreCleanup(backend: backend, store: store) { + await store.start() + async let started = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) + _ = try await started + + await store.restart() + #expect(store.reviewRuns.count == 1) + #expect(await backend.recordedCommands().contains { command in + if case .cleanupRetainedReviews = command { true } else { false } + } == false) + + let cleanupGate = AsyncGate() + await backend.holdRetainedCleanup(with: cleanupGate) + let stop = Task { @MainActor in + await store.stop() + } + try await backend.waitForRetainedCleanup(timeout: .seconds(2)) + #expect(store.reviewRuns.isEmpty) + await cleanupGate.open() + await stop.value + #expect(await backend.recordedCommands().contains { command in + if case .cleanupRetainedReviews = command { true } else { false } + }) + #expect(try await journal.load().entries.isEmpty) + } + } + + @Test func finalCleanupFailureRetiresResolverButKeepsDurableTombstone() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + await backend.failRetainedCleanup(message: "delete failed") + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + try await withRetentionStoreCleanup(backend: backend, store: store) { + await store.start() + async let started = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) + _ = try await started + + await store.stop() + + #expect(store.reviewRuns.isEmpty) + #expect(store.serverState == .stopped) + #expect(try await journal.load().entries.map(\.runID.rawValue) == ["run-1"]) + } + } + + @Test func finalRetryDoesNotCleanPersistedQuarantineTwice() async throws { + let journal = ControlledReviewThreadRetentionJournal() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }), + reviewThreadRetentionJournal: journal + ) + try await withRetentionStoreCleanup(backend: backend, store: store) { + async let started = store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) + _ = try await started + + await backend.failRetainedCleanup(message: "delete failed") + await journal.failReplacements("disk unavailable") + #expect(await store.retireReviewRunsForFinalStoreStop() == false) + + await backend.failRetainedCleanup(message: nil) + #expect(await store.retireReviewRunsForFinalStoreStop()) + + let retainedCleanups = await backend.recordedCommands().filter { command in + if case .cleanupRetainedReviews = command { true } else { false } + } + #expect(retainedCleanups.count == 2) + } + } +} + +private func makeRetentionAttempt(runID: String) -> ReviewAttempt { + makeReviewAttemptForTesting( + attemptID: "attempt-\(runID)", + sourceThreadID: "thread-\(runID)", + activeTurnThreadID: "review-thread-\(runID)", + turnID: "turn-\(runID)" + ) +} + +private actor ControlledReviewThreadRetentionJournal: ReviewThreadRetentionJournaling { + private var snapshot: ReviewThreadRetentionJournalSnapshot + private var replacementFailure: String? + private var loadFailure: String? + + init(snapshot: ReviewThreadRetentionJournalSnapshot = .init()) { + self.snapshot = snapshot + } + + func failReplacements(_ message: String?) { + replacementFailure = message + } + + func failLoads(_ message: String?) { + loadFailure = message + } + + func load() throws -> ReviewThreadRetentionJournalSnapshot { + if let loadFailure { + throw ControlledJournalError(message: loadFailure) + } + return snapshot + } + + func replace(with snapshot: ReviewThreadRetentionJournalSnapshot) throws { + if let replacementFailure { + throw ControlledJournalError(message: replacementFailure) + } + self.snapshot = snapshot + } +} + +private func makeRetentionEntry(runID: String) -> ReviewThreadRetentionEntry { + guard let validatedRunID = try? ReviewRunID(validating: runID) else { + preconditionFailure("The retention test fixture requires a nonempty run ID.") + } + return ReviewThreadRetentionEntry( + runID: validatedRunID, + scope: .init( + codexHomePath: FileManager.default.temporaryDirectory + .appendingPathComponent("CodexReviewKit-volatile", isDirectory: true) + .path, + accountKey: nil + ), + attempts: [makeReviewAttemptForTesting( + attemptID: "attempt-\(runID)", + sourceThreadID: "source-\(runID)", + activeTurnThreadID: "review-\(runID)", + turnID: "turn-\(runID)" + )] + ) +} + +private struct ControlledJournalError: LocalizedError { + var message: String + + var errorDescription: String? { + message + } +} + +private extension ReviewThreadRetentionAcceptance { + var quarantines: [ReviewThreadRetentionQuarantine]? { + guard case .quarantined(let quarantines) = self else { + return nil + } + return quarantines + } +} + +private extension ReviewBackendFailure { + var isRetentionJournalFailure: Bool { + if case .retentionJournal = self { + return true + } + return false + } +} + +@MainActor +private func withRetentionStoreCleanup( + backend: FakeCodexReviewBackend, + store: CodexReviewStore, + operation: () async throws -> T +) async rethrows -> T { + do { + let value = try await operation() + await backend.finishEventMailboxes() + await store.cancelAndDrainReviewWorkersForTesting() + return value + } catch { + await backend.finishEventMailboxes() + await store.cancelAndDrainReviewWorkersForTesting() + throw error + } +} diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 64501b34..5efc2472 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -1,7 +1,7 @@ import Darwin import Foundation -import CodexKit import MCP +import Synchronization @preconcurrency import NIOCore import Testing @_spi(Testing) @testable import CodexReviewKit @@ -70,8 +70,10 @@ struct CodexReviewMCPHTTPServerTests { ) let server = CodexReviewMCPHTTPServer( adapter: CodexReviewMCPServer(store: store), - configuration: .init(host: "review.local", port: 9417) + configuration: .init(host: "127.0.0.1", port: 0) ) + try await server.start() + let port = try #require(await server.url.port) let initializeBody = try makeJSONBody([ "jsonrpc": "2.0", "id": 1, @@ -85,22 +87,22 @@ struct CodexReviewMCPHTTPServerTests { ], ], ]) - let response = await server.handleHTTPRequest( + let response = await server.handleHTTPRequestForTesting( HTTPRequest( method: "POST", headers: [ - HTTPHeaderName.host: "review.local:9417", + HTTPHeaderName.host: "127.0.0.1:\(port)", HTTPHeaderName.accept: "text/event-stream, application/json", HTTPHeaderName.contentType: "application/json", ], body: initializeBody, path: "/mcp" )) - let denied = await server.handleHTTPRequest( + let denied = await server.handleHTTPRequestForTesting( HTTPRequest( method: "POST", headers: [ - HTTPHeaderName.host: "other.local:9417", + HTTPHeaderName.host: "other.local:\(port)", HTTPHeaderName.accept: "text/event-stream, application/json", HTTPHeaderName.contentType: "application/json", ], @@ -112,6 +114,202 @@ struct CodexReviewMCPHTTPServerTests { #expect(response.headers[HTTPHeaderName.sessionID]?.isEmpty == false) #expect(denied.statusCode == 421) await server.stop() + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) + } + + @Test func streamableHTTPRejectsRequestsUntilStagedServerIsActivated() async throws { + let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + ) + let server = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: store), + configuration: .init(host: "127.0.0.1", port: 0) + ) + let request = HTTPRequest(method: "GET", path: "/mcp") + + try await server.stage() + let stagedResponse = await server.handleHTTPRequestForTesting(request) + await server.activate() + let acceptingResponse = await server.handleHTTPRequestForTesting(request) + await server.stop() + + #expect(stagedResponse.statusCode == 503) + #expect(acceptingResponse.statusCode == 400) + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) + } + + @Test func streamableHTTPCloseDrainsInitializationBeforeLateProtocolServerBind() async throws { + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + let protocolServerGate = AsyncGate() + let (initializingSessionIDs, initializingSessionContinuation) = + AsyncStream.makeStream() + let server = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: store), + configuration: .init(host: "127.0.0.1", port: 0), + protocolServerFactory: { + adapter, + sessionID, + registry, + clientSession, + boundedReviewWaitDuration in + let protocolServer = await makeMCPProtocolServer( + adapter: adapter, + sessionID: sessionID, + sessionRegistry: registry, + clientSession: clientSession, + boundedReviewWaitDuration: boundedReviewWaitDuration + ) + initializingSessionContinuation.yield(sessionID) + do { + try await protocolServerGate.wait() + } catch { + preconditionFailure("The protocol-server test gate was cancelled: \(error)") + } + return protocolServer + } + ) + try await server.start() + let port = try #require(await server.url.port) + let initializeRequest = HTTPRequest( + method: "POST", + headers: [ + HTTPHeaderName.host: "127.0.0.1:\(port)", + HTTPHeaderName.accept: "text/event-stream, application/json", + HTTPHeaderName.contentType: "application/json", + ], + body: try makeJSONBody([ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": [ + "protocolVersion": "2025-11-25", + "capabilities": [:], + "clientInfo": [ + "name": "CodexReviewKitTests", + "version": "0.0.0", + ], + ], + ]), + path: "/mcp" + ) + + let initialization = Task { + await server.handleHTTPRequestForTesting(initializeRequest) + } + var sessionIDIterator = initializingSessionIDs.makeAsyncIterator() + let sessionID = try #require(await sessionIDIterator.next()) + let stop = Task { + await server.stop() + } + let enteredClosing = await waitUntil(timeout: .seconds(2)) { + await server.sessionIsClosingForTesting(sessionID: sessionID) + } + #expect(enteredClosing) + + await protocolServerGate.open() + let response = await initialization.value + await stop.value + initializingSessionContinuation.finish() + + #expect(response.statusCode == 503) + #expect(response.headers[HTTPHeaderName.sessionID] == nil) + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) + } + + @Test func streamableHTTPConcurrentStopJoinsOneResourceDrain() async throws { + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-concurrent-stop", + turnID: "turn-concurrent-stop" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-server-stop" }) + ) + let server = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: store), + configuration: .init(host: "127.0.0.1", port: 0) + ) + try await server.start() + let sessionID = try await initializeSession(endpoint: await server.url) + let running = try await beginRunningReview(store: store, sessionID: sessionID) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-server-stop"), + sessionID: sessionID + ) + + async let firstStop: Void = server.stop() + async let secondStop: Void = server.stop() + _ = await (firstStop, secondStop) + + #expect(running.presentation.status == .cancelled) + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) + } + + @Test func streamableHTTPGlobalStopDrainsActivePostAndEventStreamWriter() async throws { + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-global-stop", + turnID: "turn-global-stop" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let startGate = AsyncGate() + await backend.holdStartReview(with: startGate) + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-active-stop" }) + ) + let server = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: store), + configuration: .init( + host: "127.0.0.1", + port: 0, + streamHeartbeatInterval: .milliseconds(50) + ) + ) + try await server.start() + let endpoint = await server.url + let sessionID = try await initializeSession(endpoint: endpoint) + let requestBody = try makeJSONBody([ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": [ + "name": "review_start", + "arguments": [ + "cwd": "/tmp/project", + "target": ["type": "uncommittedChanges"], + ], + ], + ]) + let post = Task { + try await postJSONRPCData( + endpoint: endpoint, + sessionID: sessionID, + bodyData: requestBody + ) + } + await backend.waitForStartReview() + let eventStreamOpened = AsyncGate() + let eventStream = Task { + try await openAndCloseRawEventStream( + endpoint: endpoint, + sessionID: sessionID, + holdUntilServerCloses: true, + opened: eventStreamOpened + ) + } + try await eventStreamOpened.wait() + #expect(await server.sessionActiveRequestCountForTesting(sessionID: sessionID) == 2) + #expect(await server.resourceSnapshotForTesting().activeRequestWorkCount == 2) + + await server.stop() + _ = await post.result + _ = await eventStream.result + + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) } @Test func streamableHTTPClassifiesAddressInUseBindError() { @@ -134,8 +332,46 @@ struct CodexReviewMCPHTTPServerTests { )) } + @Test func streamableHTTPStageFailureDrainsOwnedResources() async throws { + let occupiedStore = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + let occupiedServer = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: occupiedStore), + configuration: .init(host: "127.0.0.1", port: 0) + ) + try await occupiedServer.start() + let occupiedPort = try #require(await occupiedServer.url.port) + let blockedStore = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + let blockedServer = CodexReviewMCPHTTPServer( + adapter: CodexReviewMCPServer(store: blockedStore), + configuration: .init(host: "127.0.0.1", port: occupiedPort) + ) + + do { + try await blockedServer.stage() + Issue.record("Expected the second MCP listener bind to fail.") + } catch { + #expect( + (error as? CodexReviewMCPHTTPServer.Error) + == .addressInUse(host: "127.0.0.1", port: occupiedPort) + ) + } + + await blockedServer.stop() + assertNoHTTPServerResources(await blockedServer.resourceSnapshotForTesting()) + await occupiedServer.stop() + assertNoHTTPServerResources(await occupiedServer.resourceSnapshotForTesting()) + } + @Test func streamableHTTPCallsReviewStartWithCustomTarget() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-custom-target", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -144,7 +380,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer( store: store, logProjectionProvider: { result in - ReviewMCPLogProjection( + .available(ReviewMCPLogProjection( result: result, turnID: "turn-1", threadItems: [ @@ -162,8 +398,9 @@ struct CodexReviewMCPHTTPServerTests { text: "No issues found." )) ), - ] - ) + ], + reviewOutputText: "No issues found." + )) } ) { server in let endpoint = await server.url @@ -175,7 +412,6 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_start", "arguments": [ - "sessionID": "session-1", "cwd": "/tmp/project", "target": [ "type": "custom", @@ -189,7 +425,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == false) @@ -209,13 +445,47 @@ struct CodexReviewMCPHTTPServerTests { #expect( resolved.value(for: ["result", "structuredContent", "review", "reviewResult", "state"]) as? String == "noFindings") - assertCompactLog(resolved, total: 2) + #expect(resolved.value(for: ["result", "structuredContent", "log"]) == nil) + + let read = try await postJSONRPC( + endpoint: endpoint, + sessionID: sessionID, + body: [ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": [ + "name": "review_read", + "arguments": ["runId": "run-1"], + ], + ] + ) + let log = try #require( + read.value(for: ["result", "structuredContent", "log"]) as? [String: Any] + ) + #expect((log["items"] as? [[String: Any]])?.count == 2) + let itemsPage = try #require(log["itemsPage"] as? [String: Any]) + #expect(itemsPage["total"] as? Int == 2) + #expect(itemsPage["offset"] as? Int == 0) + #expect(itemsPage["returned"] as? Int == 2) + for removedField in [ + "revision", + "orderedEntryIds", + "activeEntryIds", + "activeEntryCount", + "latestEntryId", + "finalLifecycleMessage", + "finalResult", + "truncatedFields", + ] { + #expect(log[removedField] == nil) + } let commands = await backend.recordedCommands() #expect( commands.contains( .startReview( .init( - runID: "run-1", + runID: makeHTTPTestRunID("run-1"), sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -224,13 +494,33 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPOmitsRawFindingTextFromReviewStartResult() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-finding-result", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) ) - try await withHTTPServer(store: store) { server in + let reviewOutput = """ + Review comment: + + - [P1] Pin CodexKit fallback — Package.swift:14-14 + Fresh clones can resolve a moving dependency and drift from the reviewed API. + """ + try await withHTTPServer( + store: store, + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "turn-1", + threadItems: [], + reviewOutputText: reviewOutput + )) + } + ) { server in let endpoint = await server.url let sessionID = try await initializeSession(endpoint: endpoint) let requestBody = try makeJSONBody([ @@ -250,12 +540,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(finalReview: """ - Review comment: - - - [P1] Pin CodexKit fallback — Package.swift:14-14 - Fresh clones can resolve a moving dependency and drift from the reviewed API. - """)) + await backend.yield(.completed(finalReview: reviewOutput), for: attempt) let resolved = try decodeSSEJSON(from: try await responseData) #expect( @@ -273,7 +558,11 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPBoundsClaudeReviewStartAndContinuesWithReviewAwait() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-bounded-start", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -288,7 +577,7 @@ struct CodexReviewMCPHTTPServerTests { store: store, configuration: configuration, logProjectionProvider: { result in - ReviewMCPLogProjection( + .available(ReviewMCPLogProjection( result: result, turnID: "turn-1", threadItems: [ @@ -297,8 +586,11 @@ struct CodexReviewMCPHTTPServerTests { kind: .commandExecution, content: .command(.init(command: "git diff", output: "diff")) ), - ] - ) + ], + reviewOutputText: result.presentation.status == .succeeded + ? "No issues found." + : nil + )) } ) { server in let endpoint = await server.url @@ -327,11 +619,11 @@ struct CodexReviewMCPHTTPServerTests { #expect(running.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "running") #expect(running.value(for: ["result", "structuredContent", "logs"]) == nil) #expect(running.value(for: ["result", "structuredContent", "rawLogText"]) == nil) - assertCompactLog(running, total: 1) + #expect(running.value(for: ["result", "structuredContent", "log"]) == nil) #expect( running.value(for: ["result", "structuredContent", "nextAction", "tool"]) as? String == "review_await") - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let awaited = try await postJSONRPC( endpoint: endpoint, sessionID: sessionID, @@ -362,19 +654,38 @@ struct CodexReviewMCPHTTPServerTests { #expect( awaited.value(for: ["result", "structuredContent", "review", "reviewResult", "state"]) as? String == "noFindings") - assertCompactLog(awaited, total: 1) + #expect(awaited.value(for: ["result", "structuredContent", "log"]) == nil) #expect(awaited.value(for: ["result", "structuredContent", "logs"]) == nil) } } @Test func streamableHTTPBindsReviewStartToTransportSessionWhenArgumentIsOmitted() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-session-binding", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) ) - try await withHTTPServer(store: store) { server in + try await withHTTPServer( + store: store, + logProjectionProvider: { result in + guard let attempt = result.core.attempt else { + return .unavailable + } + return .available(ReviewMCPLogProjection( + result: result, + turnID: .init(rawValue: attempt.turnID.rawValue), + threadItems: [], + reviewOutputText: result.presentation.status == .succeeded + ? "Done" + : nil + )) + } + ) { server in let endpoint = await server.url let sessionID = try await initializeSession(endpoint: endpoint) let requestBody = try makeJSONBody([ @@ -397,7 +708,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") @@ -406,7 +717,7 @@ struct CodexReviewMCPHTTPServerTests { commands.contains( .startReview( .init( - runID: "run-1", + runID: makeHTTPTestRunID("run-1"), sessionID: sessionID, request: .init( cwd: "/tmp/project", target: .custom(instructions: "Focus on test coverage.")) @@ -414,8 +725,39 @@ struct CodexReviewMCPHTTPServerTests { } } + @Test func streamableHTTPRejectsCallerSuppliedSessionSelector() async throws { + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + + try await withHTTPServer(store: store) { server in + let sessionID = try await initializeSession(endpoint: await server.url) + let response = try await postJSONRPC( + endpoint: await server.url, + sessionID: sessionID, + body: [ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": [ + "name": "review_list", + "arguments": ["sessionID": "other-session"], + ], + ] + ) + + #expect(response.value(for: ["result", "isError"]) as? Bool == true) + let content = response.value(for: ["result", "content"]) as? [[String: Any]] + #expect((content?.first?["text"] as? String)?.contains("active MCP transport session") == true) + } + } + @Test func streamableHTTPReportsFailedReviewStartAsToolError() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-failed-start", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -441,12 +783,46 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.failed("Backend failed")) + await backend.yield( + .failed( + .turnFailed( + .init( + message: "Backend failed", + code: .httpConnectionFailed(status: 503), + additionalDetails: "Retry later" + ) + ) + ), + for: attempt + ) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == true) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") #expect(resolved.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String == "failed") + #expect( + resolved.value(for: [ + "result", "structuredContent", "lifecycle", "failure", "kind", + ]) as? String == "turnFailed" + ) + #expect( + resolved.value(for: [ + "result", "structuredContent", "lifecycle", "failure", + "turnFailure", "code", "name", + ]) as? String == "httpConnectionFailed" + ) + #expect( + resolved.value(for: [ + "result", "structuredContent", "lifecycle", "failure", + "turnFailure", "code", "status", + ]) as? Int == 503 + ) + #expect( + resolved.value(for: [ + "result", "structuredContent", "lifecycle", "failure", + "turnFailure", "additionalDetails", + ]) as? String == "Retry later" + ) } } @@ -455,14 +831,32 @@ struct CodexReviewMCPHTTPServerTests { let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - try await withHTTPServer(store: store) { server in + try await withHTTPServer( + store: store, + logProjectionProvider: { result in + guard let attempt = result.core.attempt else { + return .unavailable + } + return .available(ReviewMCPLogProjection( + result: result, + turnID: .init(rawValue: attempt.turnID.rawValue), + threadItems: [], + reviewOutputText: "No issues found." + )) + } + ) { server in let sessionID = try await initializeSession(endpoint: await server.url) let included = ReviewRunRecord.makeForTesting( id: "run-included", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Uncommitted changes", + attemptID: "attempt-included", + threadID: "thread-included", + turnID: "turn-included", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) let otherSession = ReviewRunRecord.makeForTesting( @@ -470,7 +864,12 @@ struct CodexReviewMCPHTTPServerTests { sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Uncommitted changes", + attemptID: "attempt-other-session", + threadID: "thread-other-session", + turnID: "turn-other-session", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) let otherWorkspace = ReviewRunRecord.makeForTesting( @@ -478,13 +877,26 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, cwd: "/tmp/other", targetSummary: "Uncommitted changes", + attemptID: "attempt-other-workspace", + threadID: "thread-other-workspace", + turnID: "turn-other-workspace", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) store.loadForTesting( serverState: .running, reviewRuns: [included, otherSession, otherWorkspace] ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-included"), + sessionID: sessionID + ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-other-workspace"), + sessionID: sessionID + ) let response = try await postJSONRPC( endpoint: await server.url, sessionID: sessionID, @@ -495,7 +907,6 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_list", "arguments": [ - "sessionID": "other-session", "cwd": "/tmp/project", "statuses": ["succeeded"], ], @@ -505,6 +916,9 @@ struct CodexReviewMCPHTTPServerTests { let items = try #require(response.value(for: ["result", "structuredContent", "items"]) as? [[String: Any]]) #expect(items.compactMap { $0["runId"] as? String } == ["run-included"]) + let review = try #require(items.first?["review"] as? [String: Any]) + #expect(review["hasFinalReview"] as? Bool == true) + #expect(review["finalReview"] as? String == "No issues found.") } } @@ -514,14 +928,34 @@ struct CodexReviewMCPHTTPServerTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - try await withHTTPServer(store: store) { server in + try await withHTTPServer( + store: store, + logProjectionProvider: { result in + guard let attempt = result.core.attempt else { + return .unavailable + } + return .available(ReviewMCPLogProjection( + result: result, + turnID: .init(rawValue: attempt.turnID.rawValue), + threadItems: [], + reviewOutputText: result.presentation.status == .succeeded + ? "Done" + : nil + )) + } + ) { server in let sessionID = try await initializeSession(endpoint: await server.url) let includedRun = ReviewRunRecord.makeForTesting( id: "run-in-session", sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", + attemptID: "attempt-in-session", + threadID: "thread-in-session", + turnID: "turn-in-session", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) store.loadForTesting( @@ -533,11 +967,20 @@ struct CodexReviewMCPHTTPServerTests { sessionID: "other-session", cwd: "/tmp/project", targetSummary: "Other", + attemptID: "attempt-other-session", + threadID: "thread-other-session", + turnID: "turn-other-session", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ), ] ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-in-session"), + sessionID: sessionID + ) let allowed = try await postJSONRPC( endpoint: await server.url, @@ -612,7 +1055,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadLeavesLogEmptyWithoutChatProvider() async throws { + @Test func streamableHTTPRejectsSucceededReviewWithoutChatProjection() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -625,13 +1068,22 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Included", + attemptID: "attempt-semantic", + threadID: "thread-semantic", + turnID: "turn-semantic", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) store.loadForTesting( serverState: .running, reviewRuns: [runRecord] ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-semantic"), + sessionID: sessionID + ) let defaultResponse = try await postJSONRPC( endpoint: await server.url, @@ -649,56 +1101,31 @@ struct CodexReviewMCPHTTPServerTests { ] ) - #expect(defaultResponse.value(for: ["result", "structuredContent", "runId"]) as? String == "run-semantic") - #expect(defaultResponse.value(for: ["result", "structuredContent", "run"]) != nil) - #expect( - defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "status"]) as? String - == "succeeded") - #expect( - defaultResponse.value(for: ["result", "structuredContent", "lifecycle", "message"]) as? String - == "Done") - #expect( - defaultResponse.value(for: ["result", "structuredContent", "review", "hasFinalReview"]) as? Bool - == false) - #expect(defaultResponse.value(for: ["result", "structuredContent", "review", "finalReview"]) is NSNull) - #expect(defaultResponse.value(for: ["result", "structuredContent", "logs"]) == nil) - #expect(defaultResponse.value(for: ["result", "structuredContent", "logsPage"]) == nil) - #expect(defaultResponse.value(for: ["result", "structuredContent", "rawLogText"]) == nil) - - let log = try #require( - defaultResponse.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["orderedEntryIds"] as? [String] == []) - #expect(log["activeEntryIds"] as? [String] == []) - #expect(log["activeEntryCount"] as? Int == 0) - #expect(log["latestEntryId"] is NSNull) - let itemsPage = try #require(log["itemsPage"] as? [String: Any]) - #expect(itemsPage["total"] as? Int == 0) - #expect(itemsPage["limit"] as? Int == 100) - #expect(itemsPage["returned"] as? Int == 0) - let items = try #require(log["items"] as? [[String: Any]]) - #expect(items.isEmpty) + #expect(defaultResponse.value(for: ["result", "isError"]) as? Bool == true) + #expect(defaultResponse.value(for: ["result", "structuredContent"]) == nil) + let errorText = (defaultResponse.value(for: ["result", "content"]) as? [[String: Any]])? + .first?["text"] as? String + #expect(errorText == "Review output projection invariant failed for run run-semantic.") } } @Test func streamableHTTPReviewReadDoesNotProjectRunningSummaryAsLogContent() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-running-summary", + turnID: "turn-running-summary" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-tool-progress" }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let runRecord = ReviewRunRecord.makeForTesting( - id: "run-tool-progress", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Included", - status: .running, - summary: "Running" - ) - store.loadForTesting( - serverState: .running, - reviewRuns: [runRecord] + _ = try await beginRunningReview(store: store, sessionID: sessionID) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-tool-progress"), + sessionID: sessionID ) let response = try await postJSONRPC( @@ -719,44 +1146,52 @@ struct CodexReviewMCPHTTPServerTests { let log = try #require( response.value(for: ["result", "structuredContent", "log"]) as? [String: Any]) - #expect(log["activeEntryIds"] as? [String] == []) - #expect(log["activeEntryCount"] as? Int == 0) let items = try #require(log["items"] as? [[String: Any]]) #expect(items.isEmpty) + #expect(log["revision"] == nil) + #expect(log["orderedEntryIds"] == nil) + #expect(log["activeEntryIds"] == nil) + #expect(log["activeEntryCount"] == nil) } } @Test func streamableHTTPCancelsReviewByTransportScopedSelector() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + activeTurnThreadID: "thread-1", + turnID: "turn-1", + model: "gpt-5" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) + let runIDs = Mutex(["run-running", "run-other-session"]) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { + runIDs.withLock { runIDs in + precondition(runIDs.isEmpty == false) + return runIDs.removeFirst() + } + }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = ReviewRunRecord.makeForTesting( - id: "run-running", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Uncommitted changes", - threadID: "thread-1", - turnID: "turn-1", - status: .running, - summary: "Running" - ) - let otherSession = ReviewRunRecord.makeForTesting( - id: "run-other-session", + let running = try await beginRunningReview(store: store, sessionID: sessionID) + let startGate = AsyncGate() + await backend.holdStartReview(with: startGate) + await backend.planNextAttempt(makeHTTPReviewAttempt( + attemptID: "attempt-other-session", + turnID: "turn-other-session" + )) + let otherRunID = try await store.beginReview( sessionID: "other-session", - cwd: "/tmp/project", - targetSummary: "Uncommitted changes", - threadID: "thread-2", - turnID: "turn-2", - status: .running, - summary: "Running" + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - store.loadForTesting( - serverState: .running, - reviewRuns: [running, otherSession] + let otherSession = try #require(store.reviewRun(id: otherRunID)) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running"), + sessionID: sessionID ) let response = try await postJSONRPC( endpoint: await server.url, @@ -768,9 +1203,8 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "sessionID": "other-session", "cwd": "/tmp/project", - "statuses": ["running"], + "statuses": ["queued", "running"], "reason": "Stop from MCP", ], ], @@ -779,23 +1213,112 @@ struct CodexReviewMCPHTTPServerTests { #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running") #expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == true) - #expect(running.core.lifecycle.status == .cancelled) - #expect(running.core.lifecycle.cancellation?.message == "Stop from MCP") + #expect(running.presentation.status == .cancelled) + #expect(running.core.cancellation?.message == "Stop from MCP") #expect(otherSession.cancellationRequested == false) let commands = await backend.recordedCommands() #expect( commands.contains( .interruptReview( - .init(threadID: "thread-1", turnID: "turn-1", reviewThreadID: "thread-1", model: "gpt-5"), + makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + activeTurnThreadID: "thread-1", + turnID: "turn-1", + model: "gpt-5" + ), .init(message: "Stop from MCP") ))) + let closeResult = await store.closeSession("other-session") + #expect(closeResult.terminalAndDrainedRunIDs == [otherRunID]) + store.releaseClosedSession("other-session") + } + } + + @Test func streamableHTTPCancelPreservesAlreadyFinishedReviewOutput() async throws { + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + + try await withHTTPServer( + store: store, + logProjectionProvider: { result in + guard let attempt = result.core.attempt else { + return .unavailable + } + return .available(ReviewMCPLogProjection( + result: result, + turnID: .init(rawValue: attempt.turnID.rawValue), + threadItems: [], + reviewOutputText: "No issues found." + )) + } + ) { server in + let sessionID = try await initializeSession(endpoint: await server.url) + store.loadForTesting( + serverState: .running, + reviewRuns: [ + .makeForTesting( + id: "run-completed", + sessionID: sessionID, + cwd: "/tmp/project", + targetSummary: "Completed", + attemptID: "attempt-completed", + threadID: "thread-completed", + turnID: "turn-completed", + status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), + summary: "Done" + ) + ] + ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-completed"), + sessionID: sessionID + ) + + let response = try await postJSONRPC( + endpoint: await server.url, + sessionID: sessionID, + body: [ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": [ + "name": "review_cancel", + "arguments": [ + "runId": "run-completed", + "reason": "Already complete", + ], + ], + ] + ) + + #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-completed") + #expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == false) + #expect( + response.value(for: [ + "result", "structuredContent", "review", "hasFinalReview", + ]) as? Bool == true + ) + #expect( + response.value(for: [ + "result", "structuredContent", "review", "finalReview", + ]) as? String == "No issues found." + ) } } @Test func streamableHTTPCancelDefaultsSelectorToActiveRunsInTransportSession() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-default-cancel-selector", + turnID: "turn-default-cancel-selector" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-running" }) ) try await withHTTPServer(store: store) { server in @@ -805,24 +1328,26 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, cwd: "/tmp/project", targetSummary: "Completed", + attemptID: "attempt-completed", threadID: "thread-completed", turnID: "turn-completed", status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), summary: "Done" ) - let running = ReviewRunRecord.makeForTesting( - id: "run-running", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Running", - threadID: "thread-running", - turnID: "turn-running", - status: .running, - summary: "Running" - ) store.loadForTesting( serverState: .running, - reviewRuns: [completed, running] + reviewRuns: [completed] + ) + let running = try await beginRunningReview(store: store, sessionID: sessionID) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-completed"), + sessionID: sessionID + ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running"), + sessionID: sessionID ) let response = try await postJSONRPC( @@ -844,43 +1369,55 @@ struct CodexReviewMCPHTTPServerTests { #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running") #expect(response.value(for: ["result", "structuredContent", "cancelled"]) as? Bool == true) - #expect(completed.core.lifecycle.status == .succeeded) - #expect(running.core.lifecycle.status == .cancelled) + #expect(completed.presentation.status == .succeeded) + #expect(running.presentation.status == .cancelled) } } @Test func streamableHTTPReportsAmbiguousCancelSelectorCandidates() async throws { - let backend = FakeCodexReviewBackend() + let firstAttempt = makeHTTPReviewAttempt( + attemptID: "attempt-ambiguous-first", + turnID: "turn-ambiguous-first" + ) + let secondAttempt = makeHTTPReviewAttempt( + attemptID: "attempt-ambiguous-second", + turnID: "turn-ambiguous-second" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: firstAttempt) + let startGate = AsyncGate() + await backend.holdStartReview(with: startGate) + let runIDs = Mutex(["run-running-1", "run-running-2"]) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { + runIDs.withLock { runIDs in + precondition(runIDs.isEmpty == false) + return runIDs.removeFirst() + } + }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - store.loadForTesting( - serverState: .running, - reviewRuns: [ - ReviewRunRecord.makeForTesting( - id: "run-running-1", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "First", - threadID: "thread-1", - turnID: "turn-1", - status: .running, - summary: "Running" - ), - ReviewRunRecord.makeForTesting( - id: "run-running-2", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Second", - threadID: "thread-2", - turnID: "turn-2", - status: .running, - summary: "Running" - ), - ] + let firstRunID = try await store.beginReview( + sessionID: sessionID, + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + await backend.waitForStartReview() + await backend.planNextAttempt(secondAttempt) + let secondRunID = try await store.beginReview( + sessionID: sessionID, + request: .init(cwd: "/tmp/project", target: .uncommittedChanges) + ) + #expect(firstRunID.rawValue == "run-running-1") + #expect(secondRunID.rawValue == "run-running-2") + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running-1"), + sessionID: sessionID + ) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running-2"), + sessionID: sessionID ) let response = try await postJSONRPC( @@ -909,26 +1446,22 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPCancelsDocumentedRunId() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-documented-run-id", + turnID: "turn-documented-run-id" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-running" }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = ReviewRunRecord.makeForTesting( - id: "run-running", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Running", - threadID: "thread-running", - turnID: "turn-running", - status: .running, - summary: "Running" - ) - store.loadForTesting( - serverState: .running, - reviewRuns: [running] + let running = try await beginRunningReview(store: store, sessionID: sessionID) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running"), + sessionID: sessionID ) let response = try await postJSONRPC( @@ -949,13 +1482,46 @@ struct CodexReviewMCPHTTPServerTests { ) #expect(response.value(for: ["result", "structuredContent", "runId"]) as? String == "run-running") - #expect(running.core.lifecycle.status == .cancelled) + #expect(running.presentation.status == .cancelled) + } + } + + @Test func streamableHTTPMeasuresIdleWindowWithInjectedMonotonicClock() async throws { + let clock = ManualMCPHTTPServerClock() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + + try await withHTTPServer( + store: store, + configuration: .init( + port: 0, + sessionTimeout: .seconds(10), + sessionCleanupInterval: .seconds(1), + sessionClock: clock.clock + ) + ) { server in + let sessionID = try await initializeSession(endpoint: await server.url) + + try await clock.waitForSleeperCount(1) + clock.advance(by: .seconds(10)) + try await clock.waitForSleeperCount(1) + #expect(await server.sessionActiveRequestCountForTesting(sessionID: sessionID) == 0) + + clock.advance(by: .seconds(1)) + try await clock.waitForSleeperCount(1) + #expect(await server.sessionActiveRequestCountForTesting(sessionID: sessionID) == nil) } } @Test func streamableHTTPDoesNotExpireSessionWithActiveReviewRequest() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-active-request", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let gate = AsyncGate() + let clock = ManualMCPHTTPServerClock() await backend.holdStartReview(with: gate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -964,7 +1530,22 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer( store: store, - configuration: .init(port: 0, sessionTimeout: 1) + configuration: .init( + port: 0, + sessionTimeout: .seconds(1), + sessionCleanupInterval: .seconds(1), + sessionClock: clock.clock + ), + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "turn-1", + threadItems: [], + reviewOutputText: result.presentation.status == .succeeded + ? "No issues found." + : nil + )) + } ) { server in let endpoint = await server.url let sessionID = try await initializeSession(endpoint: endpoint) @@ -987,9 +1568,34 @@ struct CodexReviewMCPHTTPServerTests { bodyData: requestBody ) await backend.waitForStartReview() - await server.runSessionCleanupForTesting(now: .distantFuture) + try await clock.waitForSleeperCount(1) + clock.advance(by: .seconds(2)) + try await clock.waitForSleeperCount(1) + + let runningList = try await postJSONRPC( + endpoint: endpoint, + sessionID: sessionID, + body: [ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": [ + "name": "review_list", + "arguments": [ + "cwd": "/tmp/project", + "statuses": ["queued", "running"], + ], + ], + ] + ) + let runningItems = try #require( + runningList.value(for: ["result", "structuredContent", "items"]) + as? [[String: Any]] + ) + #expect(runningItems.compactMap { $0["runId"] as? String } == ["run-1"]) + await gate.open() - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "structuredContent", "runId"]) as? String == "run-1") @@ -1003,7 +1609,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, body: [ "jsonrpc": "2.0", - "id": 3, + "id": 4, "method": "tools/list", ] ) @@ -1013,13 +1619,19 @@ struct CodexReviewMCPHTTPServerTests { @Test func streamableHTTPDoesNotExpireSessionWithOpenEventStream() async throws { let backend = FakeCodexReviewBackend() + let clock = ManualMCPHTTPServerClock() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) try await withHTTPServer( store: store, - configuration: .init(port: 0, sessionTimeout: 1) + configuration: .init( + port: 0, + sessionTimeout: .seconds(1), + sessionCleanupInterval: .seconds(1), + sessionClock: clock.clock + ) ) { server in let endpoint = await server.url let sessionID = try await initializeSession(endpoint: endpoint) @@ -1032,7 +1644,9 @@ struct CodexReviewMCPHTTPServerTests { let httpResponse = try #require(response as? HTTPURLResponse) #expect(httpResponse.statusCode == 200) - await server.runSessionCleanupForTesting(now: .distantFuture) + try await clock.waitForSleeperCount(1) + clock.advance(by: .seconds(2)) + try await clock.waitForSleeperCount(1) let tools = try await postJSONRPC( endpoint: endpoint, @@ -1050,6 +1664,7 @@ struct CodexReviewMCPHTTPServerTests { @Test func streamableHTTPExpiresSessionAfterEventStreamDisconnects() async throws { let backend = FakeCodexReviewBackend() + let clock = ManualMCPHTTPServerClock() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -1059,8 +1674,10 @@ struct CodexReviewMCPHTTPServerTests { configuration: .init( host: "127.0.0.1", port: 0, - sessionTimeout: 1, - streamHeartbeatInterval: .milliseconds(50) + sessionTimeout: .seconds(1), + sessionCleanupInterval: .seconds(1), + streamHeartbeatInterval: .milliseconds(50), + sessionClock: clock.clock ) ) { server in let endpoint = await server.url @@ -1072,32 +1689,29 @@ struct CodexReviewMCPHTTPServerTests { } #expect(streamReleased) - await server.runSessionCleanupForTesting(now: .distantFuture) + try await clock.waitForSleeperCount(1) + clock.advance(by: .seconds(2)) + try await clock.waitForSleeperCount(1) #expect(await server.sessionActiveRequestCountForTesting(sessionID: sessionID) == nil) } } @Test func streamableHTTPKeepsRunIDCancellationInTransportSession() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-run-id-cancellation", + turnID: "turn-run-id-cancellation" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-other-session" }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let other = ReviewRunRecord.makeForTesting( - id: "run-other-session", - sessionID: "other-session", - cwd: "/tmp/project", - targetSummary: "Other", - threadID: "thread-other", - turnID: "turn-other", - status: .running, - summary: "Running" - ) - store.loadForTesting( - serverState: .running, - reviewRuns: [other] + let other = try await beginRunningReview( + store: store, + sessionID: "other-session" ) let response = try await postJSONRPC( @@ -1118,43 +1732,46 @@ struct CodexReviewMCPHTTPServerTests { ) #expect(response.value(for: ["result", "isError"]) as? Bool == true) - #expect(other.core.lifecycle.status == .running) + #expect(other.presentation.status == .running) + let closeResult = await store.closeSession("other-session") + #expect(closeResult.terminalAndDrainedRunIDs == [other.id]) + store.releaseClosedSession("other-session") } } - @Test func streamableHTTPDeleteClosesStoreSession() async throws { - let backend = FakeCodexReviewBackend() + @Test func streamableHTTPDeleteFinishesItsRequestBeforeClosingStoreSession() async throws { + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-delete-close", + turnID: "turn-delete-close" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-running" }) ) try await withHTTPServer(store: store) { server in let sessionID = try await initializeSession(endpoint: await server.url) - let running = ReviewRunRecord.makeForTesting( - id: "run-running", - sessionID: sessionID, - cwd: "/tmp/project", - targetSummary: "Running", - threadID: "thread-running", - turnID: "turn-running", - status: .running, - summary: "Running" - ) - store.loadForTesting( - serverState: .running, - reviewRuns: [running] + let running = try await beginRunningReview(store: store, sessionID: sessionID) + try await server.registerSessionMemberForTesting( + makeHTTPTestRunID("run-running"), + sessionID: sessionID ) let response = try await deleteSession(endpoint: await server.url, sessionID: sessionID) #expect(response.statusCode == 200) - #expect(running.core.lifecycle.status == .cancelled) - await #expect(throws: (any Error).self) { - try await store.startReview( - sessionID: sessionID, - request: .init(cwd: "/tmp/project", target: .uncommittedChanges) - ) - } + #expect(running.presentation.status == .cancelled) + let staleSessionResponse = try await postJSONRPCStatus( + endpoint: await server.url, + sessionID: sessionID, + body: [ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list", + ] + ) + #expect(staleSessionResponse.statusCode == 404) } } @@ -1221,6 +1838,38 @@ struct CodexReviewMCPHTTPServerTests { } } + private func beginRunningReview( + store: CodexReviewStore, + sessionID: String, + cwd: String = "/tmp/project" + ) async throws -> ReviewRunRecord { + let runID = try await store.beginReview( + sessionID: sessionID, + request: .init(cwd: cwd, target: .uncommittedChanges) + ) + try #require( + await StoreSnapshotProbe(store: store).waitUntilRunStatus( + .running, + runID: runID.rawValue + ) != nil + ) + return try #require(store.reviewRun(id: runID)) + } + + private func assertNoHTTPServerResources( + _ snapshot: CodexReviewMCPHTTPServer.ResourceSnapshot, + sourceLocation: SourceLocation = #_sourceLocation + ) { + #expect(snapshot.listenerCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.eventLoopGroupCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.sessionCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.registrySessionCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.cleanupTaskCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.requestPumpTaskCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.activeRequestWorkCount == 0, sourceLocation: sourceLocation) + #expect(snapshot.childChannelCount == 0, sourceLocation: sourceLocation) + } + private func withHTTPServer( store: CodexReviewStore, configuration: CodexReviewMCPHTTPServer.Configuration = .init(port: 0), @@ -1240,9 +1889,11 @@ struct CodexReviewMCPHTTPServerTests { do { let result = try await operation(server) await server.stop() + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) return result } catch { await server.stop() + assertNoHTTPServerResources(await server.resourceSnapshotForTesting()) throw error } } @@ -1307,7 +1958,12 @@ struct CodexReviewMCPHTTPServerTests { return data } - private nonisolated func openAndCloseRawEventStream(endpoint: URL, sessionID: String) async throws { + private nonisolated func openAndCloseRawEventStream( + endpoint: URL, + sessionID: String, + holdUntilServerCloses: Bool = false, + opened: AsyncGate? = nil + ) async throws { try await Task.detached { let components = try #require(URLComponents(url: endpoint, resolvingAgainstBaseURL: false)) let host = try #require(components.host) @@ -1382,6 +2038,21 @@ struct CodexReviewMCPHTTPServerTests { guard responseText.contains(" 200 ") else { throw testError("Unexpected HTTP response: \(responseText)") } + await opened?.open() + if holdUntilServerCloses { + while true { + let count = Darwin.recv(descriptor, &buffer, buffer.count, 0) + if count == 0 { + return + } + if count < 0 { + guard errno == ECONNRESET else { + throw currentPOSIXError() + } + return + } + } + } Darwin.shutdown(descriptor, SHUT_RDWR) }.value } @@ -1405,6 +2076,21 @@ struct CodexReviewMCPHTTPServerTests { return (data, httpResponse) } + private nonisolated func postJSONRPCStatus( + endpoint: URL, + sessionID: String, + body: [String: Any] + ) async throws -> HTTPURLResponse { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream, application/json", forHTTPHeaderField: "Accept") + request.setValue(sessionID, forHTTPHeaderField: "MCP-Session-Id") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (_, response) = try await URLSession.shared.data(for: request) + return try #require(response as? HTTPURLResponse) + } + private nonisolated func deleteSession(endpoint: URL, sessionID: String) async throws -> HTTPURLResponse { var request = URLRequest(url: endpoint) request.httpMethod = "DELETE" @@ -1446,6 +2132,158 @@ struct CodexReviewMCPHTTPServerTests { } } +private final class ManualMCPHTTPServerClock: Sendable { + private struct Sleeper { + let deadline: MCPHTTPServerClock.Instant + let continuation: + CheckedContinuation, Never> + } + + private struct SleeperCountWaiter { + let count: Int + let continuation: + CheckedContinuation, Never> + } + + private struct State { + var now = MCPHTTPServerClock.Instant.zero + var sleepers: [UUID: Sleeper] = [:] + var sleeperCountWaiters: [UUID: SleeperCountWaiter] = [:] + var isClosed = false + } + + private let state = Mutex(State()) + + var clock: MCPHTTPServerClock { + MCPHTTPServerClock( + now: { [self] in + state.withLock { $0.now } + }, + sleep: { [self] (duration: Duration) async throws(CancellationError) -> Void in + try await sleep(for: duration) + } + ) + } + + func advance(by duration: Duration) { + precondition(duration >= .zero, "A monotonic clock cannot move backwards.") + let continuations = state.withLock { state in + precondition(state.isClosed == false, "A closed manual clock cannot advance.") + state.now = state.now.advanced(by: duration) + let dueIDs = state.sleepers.compactMap { id, sleeper in + sleeper.deadline <= state.now ? id : nil + } + return dueIDs.compactMap { id in + state.sleepers.removeValue(forKey: id)?.continuation + } + } + for continuation in continuations { + continuation.resume(returning: .success(())) + } + } + + func waitForSleeperCount( + _ count: Int + ) async throws(CancellationError) { + precondition(count >= 0) + let waiterID = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + var immediateResult: Result? + state.withLock { state in + if Task.isCancelled || state.isClosed { + immediateResult = .failure(CancellationError()) + } else if state.sleepers.count >= count { + immediateResult = .success(()) + } else { + state.sleeperCountWaiters[waiterID] = SleeperCountWaiter( + count: count, + continuation: continuation + ) + } + } + if let immediateResult { + continuation.resume(returning: immediateResult) + } + } + } onCancel: { [self] in + let continuation = state.withLock { state in + state.sleeperCountWaiters.removeValue(forKey: waiterID)?.continuation + } + continuation?.resume(returning: .failure(CancellationError())) + } + try result.get() + } + + func close() { + let continuations = state.withLock { state in + guard state.isClosed == false else { + return [CheckedContinuation, Never>]() + } + state.isClosed = true + let continuations = state.sleepers.values.map(\.continuation) + + state.sleeperCountWaiters.values.map(\.continuation) + state.sleepers.removeAll() + state.sleeperCountWaiters.removeAll() + return continuations + } + for continuation in continuations { + continuation.resume(returning: .failure(CancellationError())) + } + } + + private func sleep( + for duration: Duration + ) async throws(CancellationError) { + guard duration > .zero else { + return + } + let sleeperID = UUID() + let result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + var immediateResult: Result? + var countWaiters: + [CheckedContinuation, Never>] = [] + state.withLock { state in + if Task.isCancelled || state.isClosed { + immediateResult = .failure(CancellationError()) + } else { + let deadline = state.now.advanced(by: duration) + if deadline <= state.now { + immediateResult = .success(()) + } else { + state.sleepers[sleeperID] = Sleeper( + deadline: deadline, + continuation: continuation + ) + let satisfiedWaiterIDs = state.sleeperCountWaiters.compactMap { + id, + waiter in + state.sleepers.count >= waiter.count ? id : nil + } + countWaiters = satisfiedWaiterIDs.compactMap { id in + state.sleeperCountWaiters.removeValue(forKey: id)?.continuation + } + } + } + } + for countWaiter in countWaiters { + countWaiter.resume(returning: .success(())) + } + if let immediateResult { + continuation.resume(returning: immediateResult) + } + } + } onCancel: { [self] in + let continuation = state.withLock { state in + state.sleepers.removeValue(forKey: sleeperID)?.continuation + } + continuation?.resume(returning: .failure(CancellationError())) + } + try result.get() + } +} + private nonisolated func currentPOSIXError() -> NSError { NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } @@ -1458,18 +2296,21 @@ private nonisolated func testError(_ message: String) -> NSError { ) } -private func assertCompactLog(_ response: [String: Any], total: Int) { - let log = response.value(for: ["result", "structuredContent", "log"]) as? [String: Any] - #expect(log != nil) - #expect(log?["revision"] is String) - #expect((log?["orderedEntryIds"] as? [String])?.count == min(total, 100)) - #expect((log?["activeEntryIds"] as? [String]) != nil) - #expect(log?["activeEntryCount"] is Int) - let itemsPage = log?["itemsPage"] as? [String: Any] - #expect(itemsPage?["total"] as? Int == total) - #expect(itemsPage?["limit"] as? Int == 0) - #expect(itemsPage?["returned"] as? Int == 0) - #expect((log?["items"] as? [[String: Any]])?.isEmpty == true) +private func makeHTTPTestRunID(_ rawValue: String) -> ReviewRunID { + do { + return try ReviewRunID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review run fixture: \(error)") + } +} + +private func makeHTTPReviewAttempt(attemptID: String, turnID: String) -> ReviewAttempt { + makeReviewAttemptForTesting( + attemptID: attemptID, + sourceThreadID: "source-\(attemptID)", + activeTurnThreadID: "active-\(attemptID)", + turnID: turnID + ) } private extension [String: Any] { diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 6b51462a..02e418a6 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -1,4 +1,7 @@ +import Foundation import Testing +import MCP +import CodexDataKit @testable import CodexReviewKit @testable import CodexReviewMCPServer import CodexReviewTesting @@ -22,20 +25,47 @@ struct CodexReviewMCPServerTests { ]) } + @Test func reviewRunArgumentRejectsEmptyAndWhitespaceIDs() { + for rawValue in ["", " \n\t "] { + do { + _ = try ReviewRunIDArgument.requiredValue(in: ["runId": .string(rawValue)]) + Issue.record("Expected invalid run ID \(rawValue.debugDescription)") + } catch { + #expect(error.localizedDescription == "runId must not be empty.") + } + } + } + @Test func reviewStartConvertsToSystemCommand() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeReviewAttemptForTesting( + attemptID: "attempt-review-start", + sourceThreadID: "thread-review-start", + activeTurnThreadID: "review-thread-review-start", + turnID: "turn-1" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) ) - let server = CodexReviewMCPServer(store: store) + let server = CodexReviewMCPServer( + store: store, + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "turn-1", + threadItems: [], + reviewOutputText: "No issues found." + )) + } + ) async let response = server.handle(.reviewStart( sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges), waitTimeout: nil )) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) let resolved = try await response guard case .reviewStart(let snapshot) = resolved else { @@ -44,10 +74,142 @@ struct CodexReviewMCPServerTests { } let read = snapshot.result let log = snapshot.log - #expect(read.runID == "run-1") - #expect(read.core.lifecycle.status == .succeeded) - #expect(log.finalLifecycleMessage == nil) - #expect(log.finalResult == nil) + #expect(read.runID.rawValue == "run-1") + #expect(read.presentation.status == .succeeded) + #expect(log.finalResult == "No issues found.") #expect(log.items.isEmpty) } + + @Test func succeededRunRejectsUnavailableProjection() async throws { + let runID = try ReviewRunID(validating: "run-succeeded") + let store = succeededStore(runID: runID) + let server = CodexReviewMCPServer(store: store) + + await #expect(throws: ReviewMCPError.projectionInvariantViolation(runID: runID)) { + _ = try await server.handle(.reviewRead(sessionID: nil, runID: runID)) + } + } + + @Test func reviewListResolvesSucceededRunProjection() async throws { + let runID = try ReviewRunID(validating: "run-succeeded") + let server = succeededServer(runID: runID) + + let response = try await server.handle(.reviewList( + sessionID: nil, + cwd: nil, + statuses: [.succeeded], + limit: nil + )) + + guard case .reviewList(let snapshot) = response else { + Issue.record("Expected reviewList response") + return + } + #expect(snapshot.items.count == 1) + #expect(snapshot.items.first?.result.runID == runID) + #expect(snapshot.items.first?.log.finalResult == "No issues found.") + } + + @Test func reviewCancelResolvesAlreadyFinishedRunProjection() async throws { + let runID = try ReviewRunID(validating: "run-succeeded") + let server = succeededServer(runID: runID) + + let response = try await server.handle(.reviewCancel( + sessionID: nil, + selector: .init(runID: runID), + reason: .system() + )) + + guard case .reviewCancel(let snapshot) = response else { + Issue.record("Expected reviewCancel response") + return + } + #expect(snapshot.result.runID == runID) + #expect(snapshot.result.cancelled == false) + #expect(snapshot.log.finalResult == "No issues found.") + } + + @Test func succeededRunRejectsEmptyAndMismatchedProjection() async throws { + let runID = try ReviewRunID(validating: "run-succeeded") + let store = succeededStore(runID: runID) + let emptyServer = CodexReviewMCPServer( + store: store, + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "turn-1", + threadItems: [], + reviewOutputText: nil + )) + } + ) + await #expect(throws: ReviewMCPError.projectionInvariantViolation(runID: runID)) { + _ = try await emptyServer.handle(.reviewRead(sessionID: nil, runID: runID)) + } + + let mismatchedServer = CodexReviewMCPServer( + store: store, + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "other-turn", + threadItems: [], + reviewOutputText: "No issues found." + )) + } + ) + await #expect(throws: ReviewMCPError.projectionInvariantViolation(runID: runID)) { + _ = try await mismatchedServer.handle(.reviewRead(sessionID: nil, runID: runID)) + } + } + + @Test func projectionRefreshFailureRemainsTyped() async throws { + let runID = try ReviewRunID(validating: "run-succeeded") + let failure = CodexFetchFailure.validation(.negativeFetchLimit(-1)) + let server = CodexReviewMCPServer( + store: succeededStore(runID: runID), + logProjectionProvider: { _ in .refreshFailed(failure) } + ) + + await #expect(throws: ReviewMCPError.projectionRefreshFailed(runID: runID, failure: failure)) { + _ = try await server.handle(.reviewRead(sessionID: nil, runID: runID)) + } + } + + private func succeededServer(runID: ReviewRunID) -> CodexReviewMCPServer { + CodexReviewMCPServer( + store: succeededStore(runID: runID), + logProjectionProvider: { result in + .available(ReviewMCPLogProjection( + result: result, + turnID: "turn-1", + threadItems: [], + reviewOutputText: "No issues found." + )) + } + ) + } + + private func succeededStore(runID: ReviewRunID) -> CodexReviewStore { + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: FakeCodexReviewBackend()) + ) + store.loadForTesting( + serverState: .running, + reviewRuns: [ + .makeForTesting( + id: runID.rawValue, + targetSummary: "Completed", + attemptID: "attempt-1", + threadID: "thread-1", + turnID: "turn-1", + status: .succeeded, + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_001), + summary: "Done" + ) + ] + ) + return store + } } diff --git a/Tests/CodexReviewMCPServerTests/MCPReviewSessionRegistryTests.swift b/Tests/CodexReviewMCPServerTests/MCPReviewSessionRegistryTests.swift new file mode 100644 index 00000000..13ff3eb6 --- /dev/null +++ b/Tests/CodexReviewMCPServerTests/MCPReviewSessionRegistryTests.swift @@ -0,0 +1,261 @@ +import Foundation +import Testing + +@testable import CodexReviewKit +@testable import CodexReviewMCPServer + +@Suite("MCP review session registry") +@MainActor +struct MCPReviewSessionRegistryTests { + @Test("Close joins pending start, late bind cancellation, and concurrent callers") + func closeJoinsLateStartAndConcurrentCallers() async throws { + let closeProbe = MCPRegistryCloseProbe() + let releaseProbe = MCPRegistryReleaseProbe() + let registry = MCPReviewSessionRegistry( + closeStoreSession: { sessionID in + await closeProbe.close(sessionID: sessionID) + }, + releaseStoreSession: { sessionID in + releaseProbe.release(sessionID: sessionID) + } + ) + let sessionID = "session-1" + let runID = try ReviewRunID(validating: "run-1") + await registry.openSession(sessionID) + let operation = try await registry.beginOperation(in: sessionID) + let reservation = try await registry.reserveStart(in: sessionID) + + let firstClose = await registry.beginClose(sessionID, reason: .delete) + let secondClose = await registry.beginClose(sessionID, reason: .serverStop) + await closeProbe.waitForFirstClose() + + await #expect(throws: MCPReviewSessionRegistryError.sessionNotOpen(sessionID)) { + _ = try await registry.beginOperation(in: sessionID) + } + #expect( + try await registry.bind(runID: runID, reservation: reservation) + == .sessionClosing(.delete) + ) + try await registry.finishOperation(operation) + await #expect(throws: MCPReviewSessionRegistryError.invalidOperationToken) { + try await registry.finishOperation(operation) + } + await closeProbe.setTerminalRunIDs([runID]) + await closeProbe.finishFirstClose() + + let firstReport = await firstClose.value + let secondReport = await secondClose.value + #expect(firstReport == secondReport) + #expect(firstReport.reason == .delete) + #expect(firstReport.members == [runID]) + #expect(firstReport.cancellationScheduled == [runID]) + #expect(firstReport.cancellationFinished == [runID]) + #expect(firstReport.cancellationFailed.isEmpty) + #expect(await closeProbe.closeCount == 2) + #expect(releaseProbe.releaseCount == 1) + + await registry.removeClosedSession(sessionID) + #expect(await registry.stateForTesting(sessionID: sessionID) == nil) + } + + @Test("Open session retains terminal membership until explicit removal") + func membershipLivesUntilSessionRemoval() async throws { + let sessionID = "session-1" + let runID = try ReviewRunID(validating: "run-1") + let registry = MCPReviewSessionRegistry( + closeStoreSession: { _ in + MCPReviewSessionStoreCloseResult(terminalAndDrainedRunIDs: [runID]) + }, + releaseStoreSession: { _ in } + ) + await registry.openSession(sessionID) + let reservation = try await registry.reserveStart(in: sessionID) + #expect(try await registry.bind(runID: runID, reservation: reservation) == .bound) + await #expect(throws: MCPReviewSessionRegistryError.invalidStartReservation) { + try await registry.finishStart(reservation) + } + + let memberOperation = try await registry.beginOperation( + in: sessionID, + requiringMember: runID + ) + try await registry.validateMember(runID, for: memberOperation) + #expect(try await registry.members(for: memberOperation) == [runID]) + try await registry.finishOperation(memberOperation) + let otherRunID = try ReviewRunID(validating: "other-run") + await #expect(throws: MCPReviewSessionRegistryError.runNotFound(otherRunID)) { + _ = try await registry.beginOperation( + in: sessionID, + requiringMember: otherRunID + ) + } + + let report = await registry.beginClose(sessionID, reason: .timeout).value + #expect(report.members == [runID]) + if let state = await registry.stateForTesting(sessionID: sessionID) { + guard case .closed(let storedReport) = state.phase else { + Issue.record("Expected a closed registry tombstone.") + return + } + #expect(storedReport == report) + } else { + Issue.record("Expected a closed registry tombstone.") + } + } + + @Test("Close report never fabricates cancellation completion") + func closeReportRequiresStoreDrainEvidence() async throws { + let sessionID = "session-1" + let runID = try ReviewRunID(validating: "run-1") + let registry = MCPReviewSessionRegistry( + closeStoreSession: { _ in + MCPReviewSessionStoreCloseResult( + terminalAndDrainedRunIDs: [], + failedRunIDs: [runID] + ) + }, + releaseStoreSession: { _ in } + ) + await registry.openSession(sessionID) + let reservation = try await registry.reserveStart(in: sessionID) + #expect(try await registry.bind(runID: runID, reservation: reservation) == .bound) + + let report = await registry.beginClose(sessionID, reason: .serverStop).value + + #expect(report.cancellationScheduled == [runID]) + #expect(report.cancellationFinished.isEmpty) + #expect(report.cancellationFailed == [runID]) + } + + @Test("Close driver suspends until operation and start leases drain") + func closeDriverWaitsForRegistryLeases() async throws { + let sessionID = "session-1" + let runID = try ReviewRunID(validating: "run-1") + let closeProbe = MCPImmediateCloseProbe(terminalRunIDs: [runID]) + let releaseProbe = MCPRegistryReleaseProbe() + let registry = MCPReviewSessionRegistry( + closeStoreSession: { sessionID in + await closeProbe.close(sessionID: sessionID) + }, + releaseStoreSession: { sessionID in + releaseProbe.release(sessionID: sessionID) + } + ) + await registry.openSession(sessionID) + let operation = try await registry.beginOperation(in: sessionID) + let reservation = try await registry.reserveStart(in: sessionID) + + let close = await registry.beginClose(sessionID, reason: .delete) + await closeProbe.waitForCloseCount(1) + #expect(releaseProbe.releaseCount == 0) + + #expect( + try await registry.bind(runID: runID, reservation: reservation) + == .sessionClosing(.delete) + ) + try await registry.finishOperation(operation) + + let report = await close.value + #expect(report.cancellationFinished == [runID]) + #expect(await closeProbe.closeCount == 2) + #expect(releaseProbe.releaseCount == 1) + } +} + +private actor MCPRegistryCloseProbe { + private(set) var closeCount = 0 + private var firstCloseStarted = false + private var firstCloseStartWaiters: [CheckedContinuation] = [] + private var firstCloseCanFinish = false + private var firstCloseFinishWaiters: [CheckedContinuation] = [] + private var terminalRunIDs: Set = [] + + func close(sessionID _: String) async -> MCPReviewSessionStoreCloseResult { + closeCount += 1 + let terminalRunIDsAtStart = terminalRunIDs + guard closeCount == 1 else { + return MCPReviewSessionStoreCloseResult( + terminalAndDrainedRunIDs: terminalRunIDsAtStart + ) + } + firstCloseStarted = true + let startWaiters = firstCloseStartWaiters + firstCloseStartWaiters.removeAll() + for waiter in startWaiters { + waiter.resume() + } + guard firstCloseCanFinish == false else { + return MCPReviewSessionStoreCloseResult( + terminalAndDrainedRunIDs: terminalRunIDsAtStart + ) + } + await withCheckedContinuation { continuation in + firstCloseFinishWaiters.append(continuation) + } + return MCPReviewSessionStoreCloseResult( + terminalAndDrainedRunIDs: terminalRunIDsAtStart + ) + } + + func waitForFirstClose() async { + guard firstCloseStarted == false else { + return + } + await withCheckedContinuation { continuation in + firstCloseStartWaiters.append(continuation) + } + } + + func finishFirstClose() { + firstCloseCanFinish = true + let finishWaiters = firstCloseFinishWaiters + firstCloseFinishWaiters.removeAll() + for waiter in finishWaiters { + waiter.resume() + } + } + + func setTerminalRunIDs(_ runIDs: Set) { + terminalRunIDs = runIDs + } +} + +private actor MCPImmediateCloseProbe { + private(set) var closeCount = 0 + private let terminalRunIDs: Set + private var closeCountWaiters: [(Int, CheckedContinuation)] = [] + + init(terminalRunIDs: Set) { + self.terminalRunIDs = terminalRunIDs + } + + func close(sessionID _: String) -> MCPReviewSessionStoreCloseResult { + closeCount += 1 + let readyWaiters = closeCountWaiters.filter { $0.0 <= closeCount } + closeCountWaiters.removeAll { $0.0 <= closeCount } + for (_, waiter) in readyWaiters { + waiter.resume() + } + return MCPReviewSessionStoreCloseResult( + terminalAndDrainedRunIDs: terminalRunIDs + ) + } + + func waitForCloseCount(_ expectedCount: Int) async { + guard closeCount < expectedCount else { + return + } + await withCheckedContinuation { continuation in + closeCountWaiters.append((expectedCount, continuation)) + } + } +} + +@MainActor +private final class MCPRegistryReleaseProbe { + private(set) var releaseCount = 0 + + func release(sessionID _: String) { + releaseCount += 1 + } +} diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 074e62ab..2d452eda 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -1,56 +1,52 @@ import Foundation import Testing -import CodexKit +import CodexDataKit @testable import CodexReviewKit @testable import CodexReviewMCPServer @Suite("Review MCP log projection") struct ReviewMCPLogProjectionTests { @Test func unavailableProjectionDoesNotRebuildLogFromRunLifecycle() throws { + let core = ReviewRunCore.running( + attempt: try makeAttempt(attemptID: "attempt-1", turnID: "turn-1"), + startedAt: Date(timeIntervalSince1970: 1_000) + ) let projection = ReviewMCPLogProjection.unavailable(result: .init( - runID: "run-1", - core: .init( - lifecycle: .init(status: .running), - lifecycleMessage: "Review started." - ), - cancellable: true + runID: try makeRunID("run-1"), + core: core, + presentation: .init(core: core, executionPhase: .running(attemptGeneration: 0)) )) - #expect(projection.orderedEntryIDs == []) - #expect(projection.activeEntryIDs == []) - #expect(projection.activeEntryCount == 0) - #expect(projection.latestEntryID == nil) #expect(projection.items.isEmpty) #expect(projection.finalResult == nil) } @Test func unavailableTerminalProjectionDoesNotMirrorLifecycleAsLog() throws { + let core = ReviewRunCore.succeeded( + attempt: try makeAttempt(attemptID: "attempt-2", turnID: "turn-2"), + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_234) + ) let projection = ReviewMCPLogProjection.unavailable(result: .init( - runID: "run-2", - core: .init( - lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - lifecycleMessage: "Done." - ), - cancellable: false + runID: try makeRunID("run-2"), + core: core, + presentation: .init(core: core, executionPhase: nil) )) - #expect(projection.activeEntryIDs == []) - #expect(projection.activeEntryCount == 0) - #expect(projection.finalLifecycleMessage == nil) #expect(projection.finalResult == nil) #expect(projection.items.isEmpty) } @Test func turnItemsProjectAsOrderedLogItems() throws { + let core = ReviewRunCore.running( + attempt: try makeAttempt(attemptID: "attempt-1", turnID: "turn-1"), + startedAt: Date(timeIntervalSince1970: 1_000) + ) let projection = ReviewMCPLogProjection( result: .init( - runID: "run-1", - core: .init( - run: .init(threadID: "thread-1", turnID: "turn-1"), - lifecycle: .init(status: .running), - lifecycleMessage: "Running." - ), - cancellable: true + runID: try makeRunID("run-1"), + core: core, + presentation: .init(core: core, executionPhase: .running(attemptGeneration: 0)) ), turnID: "turn-1", threadItems: [ @@ -69,43 +65,63 @@ struct ReviewMCPLogProjectionTests { kind: .commandExecution, content: .command(.init(command: "swift test", output: "passed")) ), - ] + ], + reviewOutputText: nil ) - #expect(projection.orderedEntryIDs == [ + #expect(projection.items.map(\.id) == [ "turn-1:assistant-1", "turn-1:reasoning-1", "turn-1:command-1", ]) - #expect(projection.activeEntryIDs == projection.orderedEntryIDs) - #expect(projection.activeEntryCount == 3) - #expect(projection.latestEntryID == "turn-1:command-1") #expect(projection.items.map { $0.kind } == ["agentMessage", "reasoning", "commandExecution"]) #expect(projection.items.map { $0.content.type } == ["message", "reasoning", "command"]) } @Test func terminalTurnItemsProvideFinalResultFromCodexChatOnly() throws { + let core = ReviewRunCore.succeeded( + attempt: try makeAttempt(attemptID: "attempt-1", turnID: "turn-1"), + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_234) + ) let projection = ReviewMCPLogProjection( result: .init( - runID: "run-1", - core: .init( - run: .init(threadID: "thread-1", turnID: "turn-1"), - lifecycle: .init(status: .succeeded, endedAt: Date(timeIntervalSince1970: 1_234)), - lifecycleMessage: "Done." - ), - cancellable: false + runID: try makeRunID("run-1"), + core: core, + presentation: .init(core: core, executionPhase: nil) ), turnID: "turn-1", threadItems: [ .init( id: "assistant-1", kind: .agentMessage, - content: .message(.init(id: "assistant-1", role: .assistant, text: "CodexChat final")) + content: .message(.init( + id: "assistant-1", + role: .assistant, + text: "Assistant fallback must not win." + )) ), - ] + ], + reviewOutputText: "CodexChat final" ) - #expect(projection.finalLifecycleMessage == "Done.") #expect(projection.finalResult == "CodexChat final") } } + +private func makeAttempt( + attemptID: String, + turnID: String, + threadID: String = "thread-1" +) throws -> ReviewAttempt { + try ReviewAttempt( + validatingAttemptID: attemptID, + sourceThreadID: threadID, + activeTurnThreadID: threadID, + turnID: turnID + ) +} + +private func makeRunID(_ rawValue: String) throws -> ReviewRunID { + try ReviewRunID(validating: rawValue) +} diff --git a/Tests/ReviewUITests/PreviewRuntimeLifetimeTests.swift b/Tests/ReviewUITests/PreviewRuntimeLifetimeTests.swift new file mode 100644 index 00000000..b4839e73 --- /dev/null +++ b/Tests/ReviewUITests/PreviewRuntimeLifetimeTests.swift @@ -0,0 +1,85 @@ +import CodexAppServerKit +import CodexAppServerKitTesting +import CodexDataKit +import Testing +@testable import ReviewUIPreviewSupport + +@Suite(.serialized) +@MainActor +struct PreviewRuntimeLifetimeTests { + @Test func concurrentStoreStopsShareFullCloseAndReleaseResources() async throws { + let source = ReviewMonitorPreviewContent.makeContentSource() + await source.startAndWaitForTesting() + let lifetime = try #require(source.runtimeLifetimeForTesting) + + weak var weakContainer: CodexModelContainer? + weak var weakServer: CodexAppServer? + do { + weakContainer = source.modelContainerForTesting + weakServer = lifetime.runtimeServerForTesting + } + source.startStreamingForTesting(interval: .seconds(60)) + _ = await source.appendPreviewChatLogStreamTick( + after: 0, + emitsNotifications: true + ) + #expect(source.activeRuntimeTaskCountForTesting >= 2) + + let firstStop = Task { @MainActor in + await source.store.stop() + } + let secondStop = Task { @MainActor in + await source.store.stop() + } + await firstStop.value + await secondStop.value + + #expect(source.stopOperationCountForTesting == 1) + #expect(lifetime.lifecycleStateForTesting.fullCloseCount == 1) + #expect(source.codexModelSource.modelContext == nil) + #expect(source.modelContainerForTesting == nil) + #expect(source.activeRuntimeTaskCountForTesting == 0) + #expect(weakContainer == nil) + #expect(weakServer == nil) + } + + @Test func stoppedStoreRejectsLateStreamMutationAndNotification() async throws { + let source = ReviewMonitorPreviewContent.makeContentSource() + await source.startAndWaitForTesting() + let lifetime = try #require(source.runtimeLifetimeForTesting) + let chatID = try #require(source.initialChatID) + let snapshotBeforeStop = await source.snapshotForTesting(chatID: chatID) + let notificationCountBeforeStop = lifetime.lifecycleStateForTesting.emittedNotificationCount + + await source.store.stop() + let tick = await source.appendPreviewChatLogStreamTick( + after: 42, + emitsNotifications: true + ) + + #expect(tick == 42) + #expect(await source.snapshotForTesting(chatID: chatID) == snapshotBeforeStop) + #expect(lifetime.lifecycleStateForTesting.emittedNotificationCount == notificationCountBeforeStop) + } + + @Test func sourceDropOnlySignalsCancellationSoExplicitStopRemainsRequired() async throws { + var source: ReviewMonitorPreviewContentSource? = ReviewMonitorPreviewContent.makeContentSource() + await source?.startAndWaitForTesting() + weak var weakLifetime: PreviewRuntimeLifetime? + let lifecycleState: PreviewRuntimeLifecycleState + let transport: CodexAppServerTestTransport + do { + let lifetime = try #require(source?.runtimeLifetimeForTesting) + weakLifetime = lifetime + lifecycleState = lifetime.lifecycleStateForTesting + transport = try #require(source?.runtimeTransportForTesting) + } + + source = nil + + #expect(weakLifetime == nil) + #expect(lifecycleState.fullCloseCount == 0) + + await transport.close() + } +} diff --git a/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift b/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift new file mode 100644 index 00000000..9068e208 --- /dev/null +++ b/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift @@ -0,0 +1,20 @@ +import Foundation +import Testing +@testable import CodexReviewKit +@testable import ReviewUI + +@Suite("ReviewMonitor add account action") +@MainActor +struct ReviewMonitorAddAccountActionTests { + @Test func operationFailureUsesTheAccountActionAlertFlow() async throws { + let store = CodexReviewStore.makePreviewStore() + + await ReviewMonitorAddAccountAction.perform(store: store) { + throw CodexReviewAPI.Error.io("Review runtime operations are closed.") + } + + let alert = try #require(store.auth.accountActionAlert) + #expect(String(localized: alert.title) == "Failed to Add Account") + #expect(alert.message == "Review runtime operations are closed.") + } +} diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 4118e7e3..0afd0f1b 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -1,6 +1,8 @@ import Foundation import Testing -import CodexKit +import CodexAppServerKitTesting +import CodexAppServerKit +import CodexDataKit @_spi(Testing) @testable import CodexReviewKit @testable import ReviewChatLogUI @testable import ReviewUI @@ -25,8 +27,8 @@ func makeReviewMonitorPreviewContentViewControllerForPreview( return rootViewController } -struct ReviewChatLogEntryForTesting: Sendable, Hashable { - enum Kind: String, Sendable, Hashable { +struct ReviewChatLogEntryForTesting: Sendable { + enum Kind: String, Sendable { case command case commandOutput case fileChange @@ -44,10 +46,10 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { case event } - struct Metadata: Sendable, Hashable { + struct Metadata: Sendable { var sourceType: String? var title: String? - var status: String? + var status: CodexTurnStatus? var itemID: String? var command: String? var cwd: String? @@ -55,12 +57,12 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { var startedAt: Date? var completedAt: Date? var durationMs: Int? - var commandStatus: String? + var commandStatus: CodexTurnStatus? init( sourceType: String? = nil, title: String? = nil, - status: String? = nil, + status: CodexTurnStatus? = nil, itemID: String? = nil, command: String? = nil, cwd: String? = nil, @@ -68,7 +70,7 @@ struct ReviewChatLogEntryForTesting: Sendable, Hashable { startedAt: Date? = nil, completedAt: Date? = nil, durationMs: Int? = nil, - commandStatus: String? = nil + commandStatus: CodexTurnStatus? = nil ) { self.sourceType = sourceType self.title = title @@ -115,11 +117,12 @@ struct ReviewChatFixtureForTesting { var id: CodexThreadID var title: String var preview: String? - var model: String? + var model: String var workspaceCWD: String? - var updatedAt: Date? - var recencyAt: Date? - var status: CodexThreadStatus? + var createdAt: Date + var updatedAt: Date + var recencyAt: Date + var status: CodexThreadStatus } var id: String @@ -173,7 +176,7 @@ func makeReviewChatFixtureForTesting( cwd: String = "/tmp/repo", title: String, preview: String? = nil, - model: String? = "gpt-5", + model: String = "gpt-5", chatID: CodexThreadID? = nil, turnID: CodexTurnID? = nil, status: ReviewChatFixtureStatus = .succeeded, @@ -184,7 +187,10 @@ func makeReviewChatFixtureForTesting( ) -> ReviewChatFixtureForTesting { let resolvedChatID = chatID ?? CodexThreadID(rawValue: id) let resolvedTurnID = turnID ?? CodexTurnID(rawValue: "\(id):preview-turn") - let resolvedUpdatedAt = updatedAt ?? startedAt + guard let resolvedCreatedAt = startedAt ?? updatedAt else { + preconditionFailure("A Preview test chat requires an explicit creation time.") + } + let resolvedUpdatedAt = updatedAt ?? resolvedCreatedAt let chat = ReviewChatFixtureForTesting.Chat( rowID: .chat(resolvedChatID), id: resolvedChatID, @@ -192,6 +198,7 @@ func makeReviewChatFixtureForTesting( preview: preview, model: model, workspaceCWD: cwd, + createdAt: resolvedCreatedAt, updatedAt: resolvedUpdatedAt, recencyAt: resolvedUpdatedAt, status: CodexThreadStatus(chatFixtureStatusForTesting: status) @@ -200,8 +207,10 @@ func makeReviewChatFixtureForTesting( let initialSnapshot = makeCodexThreadSnapshotForTesting( chatID: resolvedChatID, turnID: resolvedTurnID, - turnStatus: CodexTurnStatus(chatFixtureStatusForTesting: status), - turnErrorDescription: errorMessage, + turnState: CodexTurnSnapshot.State( + chatFixtureStatusForTesting: status, + errorMessage: errorMessage + ), items: ReviewChatLogFixtureStore.items(for: resolvedChatID, turnID: resolvedTurnID) ) return ReviewChatFixtureForTesting( @@ -243,13 +252,13 @@ func replaceChatLogTextForTesting( func installPreviewChatLogSourceForTesting( on store: CodexReviewStore, fixtures: [ReviewChatFixtureForTesting] -) -> ReviewMonitorPreviewAppServerRuntime { +) -> PreviewRuntimeLifetime { let fixtures = fixtures.map(makePreviewChatLogFixtureForTesting) - let runtime = ReviewMonitorPreviewAppServerRuntime(fixtures: fixtures) + let runtime = PreviewRuntimeLifetime(fixtures: fixtures) let retainer = ReviewChatLogFixtureRetainer( store: store, runtime: runtime, - chatIDs: Set(fixtures.map(\.chatID)) + cwdByChatID: Dictionary(uniqueKeysWithValues: fixtures.map { ($0.chatID, $0.cwd) }) ) runStorePreviewSupportRetainers.append(retainer) previewSupportRetainersByStore[ObjectIdentifier(store)] = retainer @@ -257,7 +266,7 @@ func installPreviewChatLogSourceForTesting( } @MainActor -func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPreviewAppServerRuntime? { +func previewRuntimeForTesting(on store: CodexReviewStore) -> PreviewRuntimeLifetime? { prunePreviewSupportRetainersByStore() guard let retainer = previewSupportRetainersByStore[ObjectIdentifier(store)], retainer.store === store @@ -270,6 +279,7 @@ func previewRuntimeForTesting(on store: CodexReviewStore) -> ReviewMonitorPrevie @MainActor private func prunePreviewSupportRetainersByStore() { + runStorePreviewSupportRetainers.removeAll { $0.store == nil } previewSupportRetainersByStore = previewSupportRetainersByStore.filter { _, retainer in retainer.store != nil } @@ -422,8 +432,8 @@ func makePreviewAppServerRuntimeForTesting( streamID: String, isRunning: Bool, initialSnapshot: CodexThreadSnapshot -) -> ReviewMonitorPreviewAppServerRuntime { - ReviewMonitorPreviewAppServerRuntime( +) -> PreviewRuntimeLifetime { + PreviewRuntimeLifetime( fixtures: [ makePreviewChatLogFixtureForTesting( chat: chat, @@ -501,8 +511,7 @@ func codexThreadSnapshotForTesting(_ fixture: ReviewChatFixtureForTesting) -> Co func makeCodexThreadSnapshotForTesting( chatID: CodexThreadID, turnID: CodexTurnID, - turnStatus: CodexTurnStatus = .completed, - turnErrorDescription: String? = nil, + turnState: CodexTurnSnapshot.State = .completed, items: [CodexThreadItem] = [] ) -> CodexThreadSnapshot { makeCodexThreadSnapshotForTesting( @@ -510,8 +519,7 @@ func makeCodexThreadSnapshotForTesting( turns: [ .init( id: turnID, - status: turnStatus, - errorMessage: turnErrorDescription, + state: turnState, items: items ) ] @@ -527,7 +535,12 @@ func makeCodexThreadSnapshotForTesting( var resolvedTurns = turns if items.isEmpty == false { if resolvedTurns.isEmpty { - resolvedTurns = [CodexTurnSnapshot(id: CodexTurnID(rawValue: "\(chatID.rawValue):preview-turn"))] + resolvedTurns = [ + CodexTurnSnapshot( + id: CodexTurnID(rawValue: "\(chatID.rawValue):preview-turn"), + state: .inProgress + ) + ] } resolvedTurns[resolvedTurns.count - 1].items = items } @@ -545,12 +558,17 @@ func makePreviewChatLogFixtureForTesting( isRunning: Bool, initialSnapshot: CodexThreadSnapshot ) -> ReviewMonitorPreviewChatLogFixture { - ReviewMonitorPreviewChatLogFixture( + guard let workspaceCWD = chat.workspaceCWD else { + preconditionFailure("A Preview test chat requires an explicit workspace.") + } + return ReviewMonitorPreviewChatLogFixture( chatID: chat.id, title: chat.title, - preview: chat.preview, + preview: chat.preview ?? chat.title, model: chat.model, - workspaceCWD: chat.workspaceCWD, + modelProvider: "openai", + workspaceCWD: workspaceCWD, + createdAt: chat.createdAt, updatedAt: chat.updatedAt, recencyAt: chat.recencyAt, status: chat.status, @@ -612,26 +630,36 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { weak var store: CodexReviewStore? - let runtime: ReviewMonitorPreviewAppServerRuntime - private var chatIDs: Set + let runtime: PreviewRuntimeLifetime + private var cwdByChatID: [CodexThreadID: String] - init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { + init( + store: CodexReviewStore, + runtime: PreviewRuntimeLifetime, + cwdByChatID: [CodexThreadID: String] + ) { self.store = store self.runtime = runtime - self.chatIDs = chatIDs + self.cwdByChatID = cwdByChatID } func contains(chatID: CodexThreadID) -> Bool { - chatIDs.contains(chatID) + cwdByChatID[chatID] != nil } func upsert(chatID: CodexThreadID, items: [CodexThreadItem]) async { + guard let cwd = cwdByChatID[chatID] else { + preconditionFailure("Missing preview fixture workspace for \(chatID.rawValue).") + } let previousItemsByID = Dictionary( uniqueKeysWithValues: await runtime.snapshotForTesting(chatID: chatID)?.items.map { ($0.id, $0) } ?? [] ) for item in items { guard let previousItem = previousItemsByID[item.id] else { - await runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + await runtime.upsertPreviewItem( + makeAppServerTestItemForTesting(item, cwd: cwd), + to: chatID + ) continue } guard previousItem != item else { @@ -654,12 +682,111 @@ private final class ReviewChatLogFixtureRetainer { content: previousItem.content ) } else { - await runtime.upsertPreviewItem(id: item.id, kind: item.kind, content: item.content, to: chatID) + await runtime.upsertPreviewItem( + makeAppServerTestItemForTesting(item, cwd: cwd), + to: chatID + ) } } } } +private func makeAppServerTestItemForTesting( + _ item: CodexThreadItem, + cwd: String +) -> CodexAppServerTestItem { + do { + switch item.content { + case .message(let message): + return try .agentMessage(id: item.id, text: message.text, phase: message.phase) + case .plan(let text): + return try .plan(id: item.id, text: text) + case .reasoning(let reasoning): + guard item.kind == .reasoning else { + preconditionFailure("Legacy reasoning fixture kinds cannot emit current-v2 notifications.") + } + return try .reasoning( + id: item.id, + summary: reasoning.summary, + content: reasoning.content + ) + case .command(let command): + return try .commandExecution( + id: item.id, + command: command.command, + cwd: URL(fileURLWithPath: command.cwd ?? cwd, isDirectory: true), + status: command.status.appServerTestCommandStatus, + aggregatedOutput: command.output, + exitCode: command.exitCode.flatMap(Int32.init(exactly:)), + duration: command.duration + ) + case .fileChange(let fileChange): + return try .fileChange( + id: item.id, + changes: [ + CodexFileUpdateChange( + path: fileChange.path ?? URL(fileURLWithPath: cwd) + .appendingPathComponent("Preview.patch").path, + kind: .update(movePath: nil), + diff: fileChange.output ?? "" + ) + ], + status: fileChange.status.appServerTestPatchStatus + ) + case .toolCall(let toolCall): + guard let server = toolCall.server, let name = toolCall.name else { + preconditionFailure("MCP preview fixtures require server and tool names.") + } + let result = previewMCPResultComponents(toolCall.result) + return try .mcpToolCall( + id: item.id, + server: server, + tool: name, + status: toolCall.status.appServerTestMCPStatus, + resultContent: result.content, + structuredContent: result.structuredContent, + resultMetadata: result.metadata, + errorMessage: toolCall.error + ) + case .contextCompaction(let text): + guard text?.isEmpty != false else { + preconditionFailure("Current-v2 context compaction has no text payload.") + } + return try .contextCompaction(id: item.id) + case .diagnostic, .log, .unknown: + preconditionFailure("Unsupported current-v2 preview item kind \(item.kind.rawValue).") + } + } catch { + preconditionFailure("Invalid current-v2 preview fixture: \(error)") + } +} + +private extension Optional where Wrapped == CodexTurnStatus { + var appServerTestCommandStatus: CodexAppServerTestItem.CommandStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } + } + + var appServerTestPatchStatus: CodexAppServerTestItem.PatchStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } + } + + var appServerTestMCPStatus: CodexAppServerTestItem.MCPStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } + } +} + @MainActor private var runStorePreviewSupportRetainers: [ReviewChatLogFixtureRetainer] = [] @MainActor @@ -681,7 +808,23 @@ private func makeChatItems( orderedIDs.append(itemID) } } - return orderedIDs.compactMap { accumulated[$0]?.snapshot } + var items = orderedIDs.compactMap { accumulated[$0]?.snapshot } + for index in items.indices.dropLast() { + switch items[index].content { + case .command(var command) where command.status == .inProgress: + command.status = .completed + items[index].content = .command(command) + case .fileChange(var fileChange) where fileChange.status == .inProgress: + fileChange.status = .completed + items[index].content = .fileChange(fileChange) + case .toolCall(var toolCall) where toolCall.status == .inProgress: + toolCall.status = .completed + items[index].content = .toolCall(toolCall) + default: + break + } + } + return items } private struct ReviewChatLogAccumulatedItem { @@ -732,7 +875,7 @@ private struct ReviewChatLogAccumulatedItem { status: status )) ) - case .agentMessage: + case .agentMessage, .progress: snapshot = .init( id: itemID, kind: .agentMessage, @@ -752,20 +895,20 @@ private struct ReviewChatLogAccumulatedItem { case .reasoning, .rawReasoning: snapshot = .init( id: itemID, - kind: entry.kind == .rawReasoning ? .init(rawValue: "rawReasoning") : .reasoning, + kind: .reasoning, content: .reasoning(.init(content: (existing?.reasoningText ?? "") + entry.text)) ) case .reasoningSummary: snapshot = .init( id: itemID, - kind: .init(rawValue: "reasoningSummary"), + kind: .reasoning, content: .reasoning(.init(summary: (existing?.reasoningText ?? "") + entry.text)) ) case .contextCompaction: snapshot = .init( id: itemID, kind: .contextCompaction, - content: .contextCompaction(entry.text) + content: .contextCompaction(nil) ) case .toolCall: snapshot = .init( @@ -773,7 +916,7 @@ private struct ReviewChatLogAccumulatedItem { kind: .mcpToolCall, content: .toolCall(.init(result: (existing?.toolResult ?? "") + entry.text, status: status)) ) - case .diagnostic, .error, .progress, .event: + case .diagnostic, .error, .event: snapshot = .init( id: itemID, kind: .init(rawValue: entry.kind.rawValue), @@ -846,28 +989,23 @@ private extension CodexThreadStatus { } } -private extension CodexTurnStatus { - init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus) { +private extension CodexTurnSnapshot.State { + init( + chatFixtureStatusForTesting status: ReviewChatFixtureStatus, + errorMessage: String? + ) { switch status { case .queued, .running: - self = .running + self = .inProgress case .succeeded: self = .completed case .failed: - self = .failed + guard let errorMessage else { + preconditionFailure("A failed review chat fixture requires an explicit error message.") + } + self = .failed(CodexTurnError(message: errorMessage)) case .cancelled: - self = .cancelled - } - } -} - -private extension CodexDataPhase { - init(chatFixtureStatusForTesting status: ReviewChatFixtureStatus, errorMessage: String?) { - switch status { - case .queued, .running, .succeeded, .cancelled: - self = .loaded - case .failed: - self = .failed(errorMessage ?? "Review failed") + self = .interrupted } } } @@ -883,10 +1021,9 @@ private func chatLogCommandText(for entry: ReviewChatLogEntryForTesting) -> Stri } private func codexTurnStatus(for entry: ReviewChatLogEntryForTesting) -> CodexTurnStatus? { - guard let rawValue = entry.metadata?.commandStatus ?? entry.metadata?.status else { - return entry.kind == .command ? .running : nil - } - return CodexTurnStatus(rawValue: rawValue) + entry.metadata?.commandStatus + ?? entry.metadata?.status + ?? (entry.kind == .command ? .inProgress : nil) } extension CodexThreadSnapshot { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index fb56b0f8..43082307 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexAppServerKitTesting import Foundation import Testing @@ -9,13 +10,68 @@ import Testing @Suite("ReviewMonitor selected Codex chat detail", .serialized) @MainActor struct ReviewMonitorCodexChatDetailTests { - @Test func logResynchronizationCanUpdateAfterInitialBaseline() async throws { + @Test func codexChatLogProjectionMapsEveryLegalTurnState() throws { + let failedError = CodexTurnError( + message: "Turn failed", + info: .sandboxError, + additionalDetails: "The command was denied." + ) + let unknownError = CodexTurnError(message: "Future status error") + let cases: + [( + name: String, + state: CodexTurnSnapshot.State, + status: CodexTurnStatus, + error: CodexTurnError? + )] = [ + ("in-progress", .inProgress, .inProgress, nil), + ("completed", .completed, .completed, nil), + ("interrupted", .interrupted, .interrupted, nil), + ("failed", .failed(failedError), .failed, failedError), + ( + "future", + .unknown(rawValue: "future", error: unknownError), + .unknown(rawValue: "future"), + unknownError + ), + ] + + for testCase in cases { + var projection = ReviewMonitorCodexChatLogProjection() + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-\(testCase.name)"), + state: testCase.state, + items: [ + .init( + id: "command-\(testCase.name)", + kind: .commandExecution, + content: .command(.init(command: "/bin/echo \(testCase.name)")) + ) + ] + ) + + let rendered = projection.render( + from: turn, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + let document = try #require(rendered) + let command = try #require(document.blocks.first { $0.kind == .command }) + + #expect(turn.status == testCase.status) + #expect(turn.error == testCase.error) + #expect(command.metadata?.status == testCase.status.rawValue) + } + } + + @Test func logSnapshotBarrierReplacesAfterInitialBaseline() async throws { let turnID = CodexTurnID(rawValue: "turn-1") - let chat = try await makeProjectionChat( + let thread = CodexThreadSnapshot( + id: "thread-1", turns: [ .init( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "log-1", @@ -28,56 +84,42 @@ struct ReviewMonitorCodexChatDetailTests { ) var projection = ReviewMonitorCodexChatLogSourceProjection() - let initialChange = projection.applyBaseline( - from: chat, - chatCreatedAt: nil, - chatUpdatedAt: nil - ) + let initialChange = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init(thread: thread, phase: .running(turnID: turnID)), reason: .initial) + )) guard case .replaceAll = initialChange else { Issue.record("Expected initial snapshot to replace the empty log") return } - let resynchronizedChange = projection.apply( - .resynchronized(reason: .refresh), - in: chat, - chatCreatedAt: nil, - chatUpdatedAt: nil - ) - guard case .update = resynchronizedChange else { - Issue.record("Expected resynchronization to update the existing log incrementally") + let refreshedChange = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .snapshot(.init(thread: thread, phase: .running(turnID: turnID)), reason: .refresh) + )) + guard case .replaceAll = refreshedChange else { + Issue.record("Expected a snapshot barrier to replace the existing projection") return } - #expect(resynchronizedChange?.allowsIncrementalRender == true) + #expect(refreshedChange?.allowsIncrementalRender == false) } @Test func selectedReviewChatRendersInitialSnapshot() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "review-thread")) try await runtime.transport.enqueueThreadRead( - .init( + try makeChatDetailStoredThread( id: "review-thread", - turns: [ - .init( - id: "turn-1", - status: .running, - items: [ - .init( - id: "message-1", - kind: .agentMessage, - content: .message( - .init( - id: "message-1", - role: .assistant, - phase: .finalAnswer, - text: "Review snapshot" - )) - ) - ] - ) - ] - )) + turnID: "turn-1", + state: .inProgress, + messageID: "message-1", + message: "Review snapshot", + phase: .finalAnswer + ) + ) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -95,32 +137,19 @@ struct ReviewMonitorCodexChatDetailTests { ) { snapshot in snapshot.log == "Review snapshot" } - #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + #expect(await runtime.transport.recordedRequests(for: .threadResume).count == 1) } @Test func switchingSelectedChatKeepsPreviousLogUntilNextBaselineRenders() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "first-thread")) - try await runtime.transport.enqueueThreadRead(.init( + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "first-thread")) + try await runtime.transport.enqueueThreadRead(try makeChatDetailStoredThread( id: "first-thread", - turns: [ - .init( - id: "turn-first", - status: .completed, - items: [ - .init( - id: "message-first", - kind: .agentMessage, - content: .message(.init( - id: "message-first", - role: .assistant, - text: "First chat log" - )) - ), - ] - ), - ] + turnID: "turn-first", + state: .completed, + messageID: "message-first", + message: "First chat log" )) let store = CodexReviewStore.makePreviewStore() @@ -140,31 +169,18 @@ struct ReviewMonitorCodexChatDetailTests { } let secondReadGate = CodexAppServerTestGate() - await runtime.transport.holdNext(method: "thread/read", gate: secondReadGate) - try await runtime.transport.enqueueThreadResume(.init(id: "second-thread")) - try await runtime.transport.enqueueThreadRead(.init( + await runtime.transport.holdNext(.threadRead, gate: secondReadGate) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "second-thread")) + try await runtime.transport.enqueueThreadRead(try makeChatDetailStoredThread( id: "second-thread", - turns: [ - .init( - id: "turn-second", - status: .completed, - items: [ - .init( - id: "message-second", - kind: .agentMessage, - content: .message(.init( - id: "message-second", - role: .assistant, - text: "Second chat log" - )) - ), - ] - ), - ] + turnID: "turn-second", + state: .completed, + messageID: "message-second", + message: "Second chat log" )) selectChat(id: "second-thread", in: uiState) - await runtime.transport.waitForRequest(method: "thread/read", count: 2) + await runtime.transport.waitForRequest(.threadRead, count: 2) try await waitForCondition { transport.renderedStateForTesting.selection == .chat("second-thread") } @@ -182,29 +198,16 @@ struct ReviewMonitorCodexChatDetailTests { @Test func switchingSelectedChatToEmptyCurrentValueClearsAfterBaseline() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "first-thread")) - try await runtime.transport.enqueueThreadRead(.init( + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "first-thread")) + try await runtime.transport.enqueueThreadRead(try makeChatDetailStoredThread( id: "first-thread", - turns: [ - .init( - id: "turn-first", - status: .completed, - items: [ - .init( - id: "message-first", - kind: .agentMessage, - content: .message(.init( - id: "message-first", - role: .assistant, - text: "First chat log" - )) - ), - ] - ), - ] + turnID: "turn-first", + state: .completed, + messageID: "message-first", + message: "First chat log" )) - try await runtime.transport.enqueueThreadResume(.init(id: "empty-thread")) - try await runtime.transport.enqueueThreadRead(.init(id: "empty-thread", turns: [])) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "empty-thread")) + try await runtime.transport.enqueueThreadRead(try makeChatDetailStoredThread(id: "empty-thread", turns: [])) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -234,8 +237,8 @@ struct ReviewMonitorCodexChatDetailTests { @Test func clearingSelectionClearsDisplayedChatLog() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) - try await runtime.transport.enqueueThreadRead(.init(id: "review-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "review-thread")) + try await runtime.transport.enqueueThreadRead(try makeChatDetailStoredThread(id: "review-thread")) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -263,30 +266,17 @@ struct ReviewMonitorCodexChatDetailTests { @Test func selectedReviewChatConnectsWhenModelSourceInstallsLater() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelSource = ReviewMonitorCodexModelSource() - try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "review-thread")) try await runtime.transport.enqueueThreadRead( - .init( + try makeChatDetailStoredThread( id: "review-thread", - turns: [ - .init( - id: "turn-1", - status: .running, - items: [ - .init( - id: "message-1", - kind: .agentMessage, - content: .message( - .init( - id: "message-1", - role: .assistant, - phase: .finalAnswer, - text: "Late source" - )) - ) - ] - ) - ] - )) + turnID: "turn-1", + state: .inProgress, + messageID: "message-1", + message: "Late source", + phase: .finalAnswer + ) + ) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -315,30 +305,17 @@ struct ReviewMonitorCodexChatDetailTests { @Test func selectedReviewChatRendersCodexChatTurnAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "review-thread")) try await runtime.transport.enqueueThreadRead( - .init( + try makeChatDetailStoredThread( id: "review-thread", - turns: [ - .init( - id: "turn-1", - status: .running, - items: [ - .init( - id: "message-1", - kind: .agentMessage, - content: .message( - .init( - id: "message-1", - role: .assistant, - phase: .finalAnswer, - text: "Chat snapshot" - )) - ) - ] - ) - ] - )) + turnID: "turn-1", + state: .inProgress, + messageID: "message-1", + message: "Chat snapshot", + phase: .finalAnswer + ) + ) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -358,17 +335,13 @@ struct ReviewMonitorCodexChatDetailTests { } #expect(initialSnapshot.log.contains("Legacy fallback") == false) - try await runtime.transport.emitServerNotification( - method: "item/updated", - params: ThreadItemParams( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - id: "message-1", - type: "agentMessage", - text: "Chat stream update", - phase: "final_answer" - ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "review-thread", + turnID: "turn-1", + item: .agentMessage( + id: "message-1", + text: "Chat stream update", + phase: .finalAnswer ) ) @@ -385,30 +358,17 @@ struct ReviewMonitorCodexChatDetailTests { @Test func codexChatTextAppendUsesAppendPath() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "review-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "review-thread")) try await runtime.transport.enqueueThreadRead( - .init( + try makeChatDetailStoredThread( id: "review-thread", - turns: [ - .init( - id: "turn-1", - status: .running, - items: [ - .init( - id: "message-1", - kind: .agentMessage, - content: .message( - .init( - id: "message-1", - role: .assistant, - phase: .finalAnswer, - text: "Initial" - )) - ) - ] - ) - ] - )) + turnID: "turn-1", + state: .inProgress, + messageID: "message-1", + message: "Initial", + phase: .finalAnswer + ) + ) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -430,17 +390,13 @@ struct ReviewMonitorCodexChatDetailTests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - try await runtime.transport.emitServerNotification( - method: "item/updated", - params: ThreadItemParams( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - id: "message-1", - type: "agentMessage", - text: "Initial log", - phase: "final_answer" - ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "review-thread", + turnID: "turn-1", + item: .agentMessage( + id: "message-1", + text: "Initial log", + phase: .finalAnswer ) ) @@ -460,7 +416,7 @@ struct ReviewMonitorCodexChatDetailTests { let turnID = CodexTurnID(rawValue: "turn-review") let turn = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "user-message", @@ -487,7 +443,142 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.text == "Review the current code changes.") } - @Test func codexChatLogProjectionDeduplicatesReviewOutputAgentMessage() async throws { + @Test func codexChatLogProjectionOnlyHidesUserPromptInMarkedTurn() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let snapshot = makeCodexThreadSnapshotForTesting( + chatID: CodexThreadID(rawValue: "review-thread"), + turns: [ + .init( + id: CodexTurnID(rawValue: "review-turn"), + state: .completed, + items: [ + .init( + id: "review-user", + kind: .userMessage, + content: .message(.init( + id: "review-user", + role: .user, + text: "Review this change." + )) + ), + .init( + id: "entered-review", + kind: .enteredReviewMode, + content: .log("Review started") + ), + ] + ), + .init( + id: CodexTurnID(rawValue: "ordinary-turn"), + state: .completed, + items: [ + .init( + id: "ordinary-user", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user", + role: .user, + text: "Keep this prompt visible." + )) + ), + ] + ), + ] + ) + + let document = projection.render( + from: snapshot, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.text.contains("Review this change.") == false) + #expect(document?.text.contains("Review started") == true) + #expect(document?.text.contains("Keep this prompt visible.") == true) + } + + @Test func codexChatLogProjectionConsumesNormalizedReviewAcrossTurns() + async throws + { + var projection = ReviewMonitorCodexChatLogProjection() + let reviewPrompt = "Review the current code changes." + let finalReview = "No actionable correctness issues were identified." + let snapshot = CodexThreadSnapshot( + id: CodexThreadID(rawValue: "review-thread"), + sourceKind: .vscode, + turns: [ + .init( + id: CodexTurnID(rawValue: "outer-review-turn"), + state: .completed, + items: [ + .init( + id: "entered-review", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "review-user-outer", + kind: .userMessage, + content: .message(.init( + id: "review-user-outer", + role: .user, + text: reviewPrompt + )) + ), + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log(finalReview) + ), + ] + ), + .init( + id: CodexTurnID(rawValue: "internal-reviewer-turn"), + state: .completed, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: reviewPrompt + )) + ), + .init( + id: "item-2", + kind: .userMessage, + content: .message(.init( + id: "item-2", + role: .user, + text: reviewPrompt + )) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: finalReview + )) + ), + ] + ), + ] + ) + + let document = projection.render( + from: snapshot, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.text == "current changes\n\n\(finalReview)") + #expect(document?.blocks.count == 2) + } + + @Test func codexChatLogProjectionSuppressesEquivalentTypedReviewRolloutCompanion() async throws { var projection = ReviewMonitorCodexChatLogProjection() let finalReview = """ Review comment: @@ -496,7 +587,7 @@ struct ReviewMonitorCodexChatDetailTests { """ let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .completed, + state: .completed, items: [ .init( id: "review-output", @@ -504,10 +595,10 @@ struct ReviewMonitorCodexChatDetailTests { content: .log(finalReview) ), .init( - id: "review-output-agent", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "review-output-agent", + id: "review_rollout_assistant", role: .assistant, text: finalReview )) @@ -524,46 +615,113 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.blocks.count == 1) } - @Test func codexChatLogProjectionKeepsMatchingReviewOutputsFromDifferentTurns() async throws { + @Test func codexChatLogProjectionConsumesNormalizedInterruptedReviewAcrossTurns() + async throws + { var projection = ReviewMonitorCodexChatLogProjection() - let finalReview = "No issues found." - let snapshot = makeCodexThreadSnapshotForTesting( - chatID: CodexThreadID(rawValue: "review-thread"), + let reviewPrompt = + "Review the current code changes (staged, unstaged, and untracked files)." + let snapshot = CodexThreadSnapshot( + id: CodexThreadID(rawValue: "interrupted-review-thread"), + sourceKind: .vscode, turns: [ .init( - id: CodexTurnID(rawValue: "turn-first"), - status: .completed, + id: CodexTurnID(rawValue: "outer-interrupted-review-turn"), + state: .interrupted, items: [ .init( - id: "review-output-first", + id: "entered-review", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "review-output", kind: .exitedReviewMode, - content: .log(finalReview) + content: .log("Reviewer failed to output a response.") + ), + ] + ), + .init( + id: CodexTurnID(rawValue: "internal-interrupted-reviewer-turn"), + state: .interrupted, + items: [ + .init( + id: "item-1", + kind: .userMessage, + content: .message(.init( + id: "item-1", + role: .user, + text: reviewPrompt + )) + ), + .init( + id: "item-2", + kind: .userMessage, + content: .message(.init( + id: "item-2", + role: .user, + text: reviewPrompt + )) ), .init( - id: "review-output-agent-first", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "review-output-agent-first", + id: "review_rollout_assistant", role: .assistant, - text: finalReview + text: "Review was interrupted. Please re-run /review." )) ), ] ), + ] + ) + + let document = projection.render( + from: snapshot, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.text == "current changes\n\nReviewer failed to output a response.") + #expect(document?.blocks.count == 2) + } + + @Test func codexChatLogProjectionKeepsGeneralAssistantWithMatchingReviewText() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let finalReview = "No issues found." + let snapshot = makeCodexThreadSnapshotForTesting( + chatID: CodexThreadID(rawValue: "review-thread"), + turns: [ .init( - id: CodexTurnID(rawValue: "turn-second"), - status: .completed, + id: CodexTurnID(rawValue: "turn-review"), + state: .completed, items: [ .init( - id: "review-output-second", + id: "review-output", kind: .exitedReviewMode, content: .log(finalReview) ), + ] + ), + .init( + id: CodexTurnID(rawValue: "ordinary-turn"), + state: .completed, + items: [ + .init( + id: "ordinary-user", + kind: .userMessage, + content: .message(.init( + id: "ordinary-user", + role: .user, + text: "Repeat the review result." + )) + ), .init( - id: "review-output-agent-second", + id: "ordinary-assistant", kind: .agentMessage, content: .message(.init( - id: "review-output-agent-second", + id: "ordinary-assistant", role: .assistant, text: finalReview )) @@ -573,56 +731,234 @@ struct ReviewMonitorCodexChatDetailTests { ] ) - let projectedDocument = projection.render( + let document = projection.render( from: snapshot, chatCreatedAt: nil, chatUpdatedAt: nil ) - let document = try #require(projectedDocument) - #expect(document.blocks.count == 2) - let documentText = document.text as NSString - let renderedText = document.blocks.map { block in - documentText.substring(with: block.range) - } - #expect(renderedText == [finalReview, finalReview]) + #expect(document?.blocks.count == 3) + #expect( + document?.text + == "\(finalReview)\n\nRepeat the review result.\n\n\(finalReview)" + ) } - @Test func codexChatLogProjectionDeduplicatesSameTurnReasoningMirrorItems() async throws { + @Test func codexChatLogProjectionSuppressesTypedReviewRolloutCompanionWithDifferentText() + async throws + { var projection = ReviewMonitorCodexChatLogProjection() - let reasoningText = """ - **Summarizing files with git** - - I need to summarize the files. - """ let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .completed, items: [ .init( - id: "event-reasoning", - kind: .reasoning, - content: .reasoning(.init(summary: reasoningText)), - rawPayload: rawPayload(type: "agent_reasoning") + id: "review-output", + kind: .exitedReviewMode, + content: .log("Review failed before producing findings.") ), .init( - id: "response-reasoning", - kind: .reasoning, - content: .reasoning(.init(summary: reasoningText)), - rawPayload: rawPayload(type: "reasoning") + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "The review child was interrupted." + )) ), ] ) - let projectedDocument = projection.render( + let document = projection.render( + from: turn, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.blocks.count == 1) + #expect(document?.text == "Review failed before producing findings.") + } + + @Test func codexChatLogProjectionKeepsReviewRolloutCompanionWhenTargetIsMissing() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-review"), + state: .completed, + items: [ + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "The review child was interrupted." + )) + ), + ] + ) + + let document = projection.render( + from: turn, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.blocks.count == 1) + #expect(document?.text == "The review child was interrupted.") + + let policyResult = ReviewRolloutPresentationPolicy().evaluate([ + .init( + sourceID: "turn-review:agentMessage:review_rollout_assistant", + turnID: CodexTurnID(rawValue: "turn-review"), + kind: .agentMessage, + origin: .reviewRolloutAssistant, + semanticRelation: .companionOf(.exitedReviewMode), + displayText: "The review child was interrupted." + ) + ]) + #expect( + policyResult.missingTargetSourceIDs + == ["turn-review:agentMessage:review_rollout_assistant"] + ) + } + + @Test func reviewRolloutPolicyDoesNotUseOutputFromAnEarlierReview() { + let firstTurnID = CodexTurnID(rawValue: "first-review") + let secondTurnID = CodexTurnID(rawValue: "second-review") + let companionTurnID = CodexTurnID(rawValue: "second-review-companion") + let companionSourceID = "second-review-companion:agentMessage:companion" + let policyResult = ReviewRolloutPresentationPolicy().evaluate([ + .init( + sourceID: "first-review:exitedReviewMode:output", + turnID: firstTurnID, + kind: .exitedReviewMode, + origin: .currentV2Item, + semanticRelation: nil, + displayText: "No issues found." + ), + .init( + sourceID: "second-review:enteredReviewMode:input", + turnID: secondTurnID, + kind: .enteredReviewMode, + origin: .currentV2Item, + semanticRelation: nil, + displayText: "current changes" + ), + .init( + sourceID: companionSourceID, + turnID: companionTurnID, + kind: .agentMessage, + origin: .reviewRolloutAssistant, + semanticRelation: .companionOf(.exitedReviewMode), + displayText: "The second review was interrupted." + ), + ]) + + #expect(policyResult.suppressedCompanionSourceIDs.isEmpty) + #expect(policyResult.missingTargetSourceIDs == [companionSourceID]) + #expect(policyResult.hiddenUserMessageTurnIDs == [companionTurnID]) + } + + @Test func codexChatLogProjectionKeepsMatchingReviewOutputsFromDifferentTurns() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let finalReview = "No issues found." + let snapshot = makeCodexThreadSnapshotForTesting( + chatID: CodexThreadID(rawValue: "review-thread"), + turns: [ + .init( + id: CodexTurnID(rawValue: "turn-first"), + state: .completed, + items: [ + .init( + id: "review-output-first", + kind: .exitedReviewMode, + content: .log(finalReview) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: finalReview + )) + ), + ] + ), + .init( + id: CodexTurnID(rawValue: "turn-second"), + state: .completed, + items: [ + .init( + id: "review-output-second", + kind: .exitedReviewMode, + content: .log(finalReview) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: finalReview + )) + ), + ] + ), + ] + ) + + let projectedDocument = projection.render( + from: snapshot, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + let document = try #require(projectedDocument) + + #expect(document.blocks.count == 2) + let documentText = document.text as NSString + let renderedText = document.blocks.map { block in + documentText.substring(with: block.range) + } + #expect(renderedText == [finalReview, finalReview]) + } + + @Test func codexChatLogProjectionKeepsDistinctReasoningItemsRegardlessOfRawPayload() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let reasoningText = """ + **Summarizing files with git** + + I need to summarize the files. + """ + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-review"), + state: .inProgress, + items: [ + .init( + id: "event-reasoning", + kind: .reasoning, + content: .reasoning(.init(summary: reasoningText)), + rawPayload: rawPayload(type: "agent_reasoning") + ), + .init( + id: "response-reasoning", + kind: .reasoning, + content: .reasoning(.init(summary: reasoningText)), + rawPayload: rawPayload(type: "reasoning") + ), + ] + ) + + let projectedDocument = projection.render( from: turn, chatCreatedAt: nil, chatUpdatedAt: nil ) let document = try #require(projectedDocument) - #expect(document.blocks.count == 1) - #expect(document.text == "Summarizing files with git\n\nI need to summarize the files.") + #expect(document.blocks.count == 2) + let renderedReasoning = "Summarizing files with git\n\nI need to summarize the files." + #expect(document.text == "\(renderedReasoning)\n\n\(renderedReasoning)") } @Test func codexChatLogProjectionKeepsAdjacentMatchingReasoningWithoutMirrorPayloads() async throws { @@ -630,7 +966,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Checking diff" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "reasoning-a", @@ -663,7 +999,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Checking diff" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "reasoning-a", @@ -704,7 +1040,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: CodexTurnID(rawValue: "turn-first"), - status: .completed, + state: .completed, items: [ .init( id: "reasoning-first", @@ -715,7 +1051,7 @@ struct ReviewMonitorCodexChatDetailTests { ), .init( id: CodexTurnID(rawValue: "turn-second"), - status: .completed, + state: .completed, items: [ .init( id: "reasoning-second", @@ -738,7 +1074,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document.text == "\(reasoningText)\n\n\(reasoningText)") } - @Test func codexChatLogProjectionIgnoresLateReasoningMirrorBeforeCommand() async throws { + @Test func codexChatLogProjectionKeepsLateDistinctReasoningBeforeCommand() async throws { var projection = ReviewMonitorCodexChatLogProjection() let turnID = CodexTurnID(rawValue: "turn-review") let reasoningText = """ @@ -751,13 +1087,13 @@ struct ReviewMonitorCodexChatDetailTests { kind: .commandExecution, content: .command(.init( command: "/bin/zsh -lc", - status: .running, + status: .inProgress, startedAt: Date(timeIntervalSince1970: 4_000) )) ) let initial = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -770,7 +1106,7 @@ struct ReviewMonitorCodexChatDetailTests { ) let mirrored = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -801,17 +1137,17 @@ struct ReviewMonitorCodexChatDetailTests { ) let mirroredDocument = try #require(projectedMirroredDocument) - #expect(mirroredDocument.text == initialDocument.text) - #expect(mirroredDocument.blocks == initialDocument.blocks) - #expect(mirroredDocument.revision == initialDocument.revision) + #expect(mirroredDocument.blocks.count == initialDocument.blocks.count + 1) + #expect(mirroredDocument.text != initialDocument.text) + #expect(mirroredDocument.revision > initialDocument.revision) } - @Test func codexChatLogProjectionDeduplicatesReasoningMirrorSeparatedByCommand() async throws { + @Test func codexChatLogProjectionKeepsDistinctReasoningSeparatedByCommand() async throws { var projection = ReviewMonitorCodexChatLogProjection() let reasoningText = "Inspecting differences" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -840,16 +1176,16 @@ struct ReviewMonitorCodexChatDetailTests { ) let document = try #require(projectedDocument) - #expect(document.blocks.count == 2) - #expect(document.text == "\(reasoningText)\n\n$ /bin/zsh -lc") + #expect(document.blocks.count == 3) + #expect(document.text == "\(reasoningText)\n\n$ /bin/zsh -lc\n\n\(reasoningText)") } - @Test func codexChatLogProjectionDeduplicatesReasoningMirrorWithWrappedItemPayload() async throws { + @Test func codexChatLogProjectionDoesNotDecodeWrappedRawPayloadForReasoningIdentity() async throws { var projection = ReviewMonitorCodexChatLogProjection() let reasoningText = "Inspecting differences" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -873,11 +1209,11 @@ struct ReviewMonitorCodexChatDetailTests { ) let document = try #require(projectedDocument) - #expect(document.blocks.count == 1) - #expect(document.text == reasoningText) + #expect(document.blocks.count == 2) + #expect(document.text == "\(reasoningText)\n\n\(reasoningText)") } - @Test func codexChatLogProjectionKeepsRepeatedReasoningAfterMirrorPairConsumed() async throws { + @Test func codexChatLogProjectionKeepsAllRepeatedReasoningItems() async throws { var projection = ReviewMonitorCodexChatLogProjection() let reasoningText = "Inspecting differences" func reasoningItem(id: String, payloadType: String) -> CodexThreadItem { @@ -890,7 +1226,7 @@ struct ReviewMonitorCodexChatDetailTests { } let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ reasoningItem(id: "event-reasoning-1", payloadType: "agent_reasoning"), reasoningItem(id: "response-reasoning-1", payloadType: "reasoning"), @@ -906,15 +1242,15 @@ struct ReviewMonitorCodexChatDetailTests { ) let document = try #require(projectedDocument) - #expect(document.blocks.count == 2) - #expect(document.text == "\(reasoningText)\n\n\(reasoningText)") + #expect(document.blocks.count == 4) + #expect(document.text == Array(repeating: reasoningText, count: 4).joined(separator: "\n\n")) } @Test func codexChatLogProjectionRendersUserMessageWithoutReviewModeLog() async throws { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-chat"), - status: .running, + state: .inProgress, items: [ .init( id: "user-message", @@ -940,7 +1276,7 @@ struct ReviewMonitorCodexChatDetailTests { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-command"), - status: .completed, + state: .completed, items: [ .init( id: "command-running", @@ -948,7 +1284,7 @@ struct ReviewMonitorCodexChatDetailTests { content: .command(.init( command: "/bin/zsh -lc", output: "done", - status: .running, + status: .inProgress, startedAt: Date(timeIntervalSince1970: 4_000) )) ), @@ -979,7 +1315,7 @@ struct ReviewMonitorCodexChatDetailTests { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-command"), - status: .completed, + state: .completed, items: [ .init( id: "command-failed", @@ -988,7 +1324,7 @@ struct ReviewMonitorCodexChatDetailTests { command: "/bin/zsh -lc", output: "error", exitCode: 1, - status: .running, + status: .inProgress, startedAt: Date(timeIntervalSince1970: 4_000) )) ), @@ -1017,11 +1353,9 @@ struct ReviewMonitorCodexChatDetailTests { @Test func codexChatStatusOnlyChangesKeepIncrementalLogUpdates() async throws { var projection = ReviewMonitorCodexChatLogSourceProjection() let turnID = CodexTurnID(rawValue: "turn-review") - let chat = try await makeProjectionChat( - turns: [ - .init( + let initialTurn = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "message-review", @@ -1033,32 +1367,134 @@ struct ReviewMonitorCodexChatDetailTests { )) ), ] - ), - ] ) - - let initialChange = projection.applyBaseline(from: chat, chatCreatedAt: nil, chatUpdatedAt: nil) - let statusChange = projection.apply( - .turnUpdated(id: turnID), - in: chat, - chatCreatedAt: nil, - chatUpdatedAt: nil + let thread = CodexThreadSnapshot( + id: "thread-review", + turns: [initialTurn] ) + let initialChange = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init(thread: thread, phase: .running(turnID: turnID)), reason: .initial) + )) + var completedTurn = initialTurn + completedTurn.state = .completed + let statusChange = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnUpdated(completedTurn, index: 0)) + )) + #expect(initialChange?.allowsIncrementalRender == false) #expect(statusChange?.allowsIncrementalRender == true) #expect(statusChange?.sourceDocument?.text == "Running review") } + @Test func codexChatTurnUpdatesIgnoreNonSemanticRawPayloadEncodingChanges() async throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-raw-payload") + let item = CodexThreadItem( + id: "message-raw-payload", + kind: .agentMessage, + content: .message(.init( + id: "message-raw-payload", + role: .assistant, + text: "Stable message" + )), + rawPayload: Data(#"{"type":"agentMessage","text":"Stable message"}"#.utf8) + ) + let initialTurn = CodexTurnSnapshot( + id: turnID, + state: .inProgress, + items: [item] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init(id: "thread-raw-payload", turns: [initialTurn]), + phase: .running(turnID: turnID) + ), reason: .initial) + )) + + var reencodedItem = item + reencodedItem.rawPayload = Data( + #"{"text":"Stable message","type":"agentMessage"}"#.utf8 + ) + let updatedTurn = CodexTurnSnapshot( + id: turnID, + state: .completed, + items: [reencodedItem] + ) + let update = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnUpdated(updatedTurn, index: 0)) + )) + + #expect(update?.allowsIncrementalRender == true) + #expect(update?.sourceDocument?.text == "Stable message") + } + + @Test func codexChatTurnUpdatesIgnoreReasoningFragmentBoundaryChanges() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-reasoning-fragments") + let item = CodexThreadItem( + id: "reasoning-fragments", + kind: .reasoning, + content: .reasoning(.init(summary: ["First"], content: [])) + ) + let initialTurn = CodexTurnSnapshot( + id: turnID, + state: .inProgress, + items: [item] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init(id: "thread-reasoning-fragments", turns: [initialTurn]), + phase: .running(turnID: turnID) + ), reason: .initial) + )) + + _ = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemTextAppended( + .init(item: item, turnID: turnID), + delta: "\n\nSecond" + )) + )) + + var canonicalItem = item + canonicalItem.content = .reasoning(.init(summary: ["First", "Second"], content: [])) + let canonicalTurn = CodexTurnSnapshot( + id: turnID, + state: .completed, + items: [canonicalItem] + ) + let update = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.turnUpdated(canonicalTurn, index: 0)) + )) + + #expect(update?.allowsIncrementalRender == true) + #expect(update?.sourceDocument?.text == "First\n\nSecond") + } + @Test func codexChatSourceProjectionKeepsTranscriptWhenNewTurnStartsWithoutRenderableText() async throws { var projection = ReviewMonitorCodexChatLogSourceProjection() let firstTurnID = CodexTurnID(rawValue: "turn-review") let secondTurnID = CodexTurnID(rawValue: "turn-reasoning") - let initialChat = try await makeProjectionChat( + let initialThread = CodexThreadSnapshot( + id: "thread-review", turns: [ .init( id: firstTurnID, - status: .running, + state: .inProgress, items: [ .init( id: "message-review", @@ -1073,85 +1509,792 @@ struct ReviewMonitorCodexChatDetailTests { ), ] ) - let updatedChat = try await makeProjectionChat( + let insertedTurn = CodexTurnSnapshot( + id: secondTurnID, + state: .inProgress, + items: [ + .init( + id: "reasoning-empty", + kind: .reasoning, + content: .reasoning(.empty) + ), + ] + ) + + let initialChange = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot( + .init(thread: initialThread, phase: .running(turnID: firstTurnID)), + reason: .initial + ) + )) + let updatedChange = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnInserted(insertedTurn, index: 1)) + )) + + #expect(initialChange?.sourceDocument?.text == "Existing review log") + guard case .update(let document) = updatedChange else { + Issue.record("Expected the existing chat transcript to stay rendered while a new empty turn starts.") + return + } + #expect(document.text == "Existing review log") + #expect(updatedChange?.allowsIncrementalRender == true) + } + + @Test func codexChatSourceProjectionAppliesTextDeltaWithoutReadingLiveGraph() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-delta") + let item = CodexThreadItem( + id: "message-delta", + kind: .agentMessage, + content: .message(.init( + id: "message-delta", + role: .assistant, + text: "Initial" + )) + ) + let thread = CodexThreadSnapshot( + id: "thread-delta", + turns: [.init(id: turnID, state: .inProgress, items: [item])] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot( + .init(thread: thread, phase: .running(turnID: turnID)), + reason: .initial + ) + )) + + let change = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemTextAppended( + .init(item: item, turnID: turnID), + delta: " log" + )) + )) + + #expect(change?.sourceDocument?.text == "Initial log") + #expect(change?.allowsIncrementalRender == true) + } + + @Test func codexChatItemLocatorDistinguishesReviewMarkersWithSameRawID() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-marker-locator") + let userItem = CodexThreadItem( + id: "user", + kind: .userMessage, + content: .message(.init(id: "user", role: .user, text: "User prompt")) + ) + let entered = CodexThreadItem( + id: "shared-marker", + kind: .enteredReviewMode, + content: .log("Entered") + ) + let exited = CodexThreadItem( + id: "shared-marker", + kind: .exitedReviewMode, + content: .log("Exited") + ) + let thread = CodexThreadSnapshot( + id: "thread-marker-locator", + turns: [.init( + id: turnID, + state: .completed, + items: [userItem, entered, exited] + )] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: thread, + phase: .terminal(turnID: turnID, disposition: .completed) + ), reason: .initial) + )) + + let afterEnteredRemoval = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemRemoved(.init(item: entered, turnID: turnID))) + )) + #expect(afterEnteredRemoval?.sourceDocument?.text.contains("Exited") == true) + #expect(afterEnteredRemoval?.sourceDocument?.text.contains("User prompt") == false) + + let afterExitedRemoval = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.itemRemoved(.init(item: exited, turnID: turnID))) + )) + #expect(afterExitedRemoval?.sourceDocument?.text == "User prompt") + } + + @Test func codexChatSourceProjectionAppliesAfterIndexesForUpdatedItemsAndTurns() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let firstTurnID = CodexTurnID(rawValue: "turn-first") + let secondTurnID = CodexTurnID(rawValue: "turn-second") + let firstItem = CodexThreadItem( + id: "message-first", + kind: .agentMessage, + content: .message(.init(id: "message-first", role: .assistant, text: "First")) + ) + let secondItem = CodexThreadItem( + id: "message-second", + kind: .agentMessage, + content: .message(.init(id: "message-second", role: .assistant, text: "Second")) + ) + let thirdItem = CodexThreadItem( + id: "message-third", + kind: .agentMessage, + content: .message(.init(id: "message-third", role: .assistant, text: "Third")) + ) + let firstTurn = CodexTurnSnapshot( + id: firstTurnID, + state: .completed, + items: [firstItem, secondItem] + ) + let secondTurn = CodexTurnSnapshot( + id: secondTurnID, + state: .completed, + items: [thirdItem] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init(id: "thread-reorder", turns: [firstTurn, secondTurn]), + phase: .terminal(turnID: secondTurnID, disposition: .completed) + ), reason: .initial) + )) + + var updatedFirstItem = firstItem + updatedFirstItem.content = .message(.init( + id: "message-first", + role: .assistant, + text: "First updated" + )) + let itemMove = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemUpdated( + item: updatedFirstItem, + turnID: firstTurnID, + index: 1 + )) + )) + let itemMoveText = try #require(itemMove?.sourceDocument?.text) + let secondRange = try #require(itemMoveText.range(of: "Second")) + let updatedFirstRange = try #require(itemMoveText.range(of: "First updated")) + #expect(secondRange.lowerBound < updatedFirstRange.lowerBound) + + var updatedFirstTurn = firstTurn + updatedFirstTurn.items = [secondItem, updatedFirstItem] + let turnMove = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.turnUpdated(updatedFirstTurn, index: 1)) + )) + let turnMoveText = try #require(turnMove?.sourceDocument?.text) + let thirdRange = try #require(turnMoveText.range(of: "Third")) + let movedSecondRange = try #require(turnMoveText.range(of: "Second")) + #expect(thirdRange.lowerBound < movedSecondRange.lowerBound) + } + + @Test func codexChatSourceProjectionReevaluatesEnteredReviewMarkerAfterTurnMove() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let markerTurnID = CodexTurnID(rawValue: "turn-review-marker") + let companionTurnID = CodexTurnID(rawValue: "turn-review-companion") + let markerTurn = CodexTurnSnapshot( + id: markerTurnID, + state: .completed, + items: [.init( + id: "review-marker", + kind: .enteredReviewMode, + content: .log("Reviewing current changes") + )] + ) + let companionTurn = CodexTurnSnapshot( + id: companionTurnID, + state: .completed, + items: [ + .init( + id: "review-user", + kind: .userMessage, + content: .message(.init( + id: "review-user", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "Review was interrupted." + )) + ), + ] + ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init( + id: "thread-review-marker-move", + turns: [markerTurn, companionTurn] + ), + phase: .terminal(turnID: companionTurnID, disposition: .completed) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text.contains("Review the current changes.") == false) + + let moved = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnUpdated(markerTurn, index: 1)) + )) + + #expect(moved?.sourceDocument?.text.contains("Review the current changes.") == true) + } + + @Test func codexChatSourceProjectionTargetsMarkerAndRolloutPolicyChanges() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-review-policy") + let user = CodexThreadItem( + id: "user-review-policy", + kind: .userMessage, + content: .message(.init( + id: "user-review-policy", + role: .user, + text: "Review this change." + )) + ) + let companion = CodexThreadItem( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "No issues found." + )) + ) + let initialTurn = CodexTurnSnapshot( + id: turnID, + state: .inProgress, + items: [user, companion] + ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init(id: "thread-review-policy", turns: [initialTurn]), + phase: .running(turnID: turnID) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text == "Review this change.\n\nNo issues found.") + + let matchingTarget = CodexThreadItem( + id: "review-output", + kind: .exitedReviewMode, + content: .log("No issues found.") + ) + let matching = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemInserted( + item: matchingTarget, + turnID: turnID, + index: 1 + )) + )) + #expect(matching?.sourceDocument?.text == "No issues found.") + #expect(matching?.sourceDocument?.blocks.count == 1) + + var differentTarget = matchingTarget + differentTarget.content = .log("Review stopped before producing findings.") + let different = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.itemUpdated( + item: differentTarget, + turnID: turnID, + index: 1 + )) + )) + #expect( + different?.sourceDocument?.text == "Review stopped before producing findings." + ) + + let removed = projection.apply(.init( + generation: 1, + sequence: 3, + payload: .update(.itemRemoved(.init(item: differentTarget, turnID: turnID))) + )) + #expect(removed?.sourceDocument?.text == "Review this change.\n\nNo issues found.") + } + + @Test func codexChatSourceProjectionAppliesReviewThreadPolicyAcrossTurns() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let outerTurnID = CodexTurnID(rawValue: "outer-review-turn-stream") + let innerTurnID = CodexTurnID(rawValue: "inner-review-turn-stream") + let finalReview = "No actionable correctness issues were identified." + let companion = CodexThreadItem( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: finalReview + )) + ) + let thread = CodexThreadSnapshot( + id: "review-thread-stream", + sourceKind: .vscode, turns: [ .init( - id: firstTurnID, - status: .running, + id: outerTurnID, + state: .inProgress, + items: [.init( + id: "entered-review-stream", + kind: .enteredReviewMode, + content: .log("current changes") + )] + ), + .init( + id: innerTurnID, + state: .completed, items: [ .init( - id: "message-review", - kind: .agentMessage, + id: "review-user-stream", + kind: .userMessage, content: .message(.init( - id: "message-review", - role: .assistant, - text: "Existing review log" + id: "review-user-stream", + role: .user, + text: "Review the current code changes." )) ), + companion, ] ), + ] + ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: thread, + phase: .running(turnID: outerTurnID) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text == "current changes\n\n\(finalReview)") + + let reviewOutput = CodexThreadItem( + id: "review-output-stream", + kind: .exitedReviewMode, + content: .log(finalReview) + ) + let completed = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemInserted( + item: reviewOutput, + turnID: outerTurnID, + index: 1 + )) + )) + + let document = try #require(completed?.sourceDocument) + #expect(document.text == "current changes\n\n\(finalReview)") + #expect(document.blocks.count == 2) + #expect(document.blocks.contains { $0.id.rawValue.contains("review-output-stream") }) + #expect(document.blocks.contains { $0.id.rawValue.contains("review_rollout_assistant") } == false) + } + + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAfterTurnMove() + throws + { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let outerTurnID = CodexTurnID(rawValue: "outer-review-turn-move") + let interveningTurnID = CodexTurnID(rawValue: "intervening-turn-move") + let companionTurnID = CodexTurnID(rawValue: "companion-turn-move") + let reviewOutput = "No actionable issues were identified." + let outerTurn = CodexTurnSnapshot( + id: outerTurnID, + state: .completed, + items: [.init( + id: "review-output-move", + kind: .exitedReviewMode, + content: .log(reviewOutput) + )] + ) + let interveningTurn = CodexTurnSnapshot( + id: interveningTurnID, + state: .completed, + items: [.init( + id: "intervening-message-move", + kind: .agentMessage, + content: .message(.init( + id: "intervening-message-move", + role: .assistant, + text: "Intervening output" + )) + )] + ) + let companionTurn = CodexTurnSnapshot( + id: companionTurnID, + state: .completed, + items: [ + .init( + id: "review-user-move-1", + kind: .userMessage, + content: .message(.init( + id: "review-user-move-1", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review-user-move-2", + kind: .userMessage, + content: .message(.init( + id: "review-user-move-2", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "Review was interrupted." + )) + ), + ] + ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init( + id: "thread-review-turn-move", + turns: [outerTurn, interveningTurn, companionTurn] + ), + phase: .terminal(turnID: companionTurnID, disposition: .completed) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text == "\(reviewOutput)\n\nIntervening output") + + let moved = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnUpdated(interveningTurn, index: 2)) + )) + + #expect(moved?.sourceDocument?.text == "\(reviewOutput)\n\nIntervening output") + } + + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAcrossStructuralChanges() + throws + { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let outerTurnID = CodexTurnID(rawValue: "outer-review-structural-change") + let companionTurnID = CodexTurnID(rawValue: "companion-review-structural-change") + let interveningTurnID = CodexTurnID(rawValue: "intervening-review-structural-change") + let reviewOutput = "No actionable issues were identified." + let outerTurn = CodexTurnSnapshot( + id: outerTurnID, + state: .completed, + items: [.init( + id: "review-output-structural-change", + kind: .exitedReviewMode, + content: .log(reviewOutput) + )] + ) + let companionTurn = CodexTurnSnapshot( + id: companionTurnID, + state: .completed, + items: [ + .init( + id: "review-user-structural-change-1", + kind: .userMessage, + content: .message(.init( + id: "review-user-structural-change-1", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review-user-structural-change-2", + kind: .userMessage, + content: .message(.init( + id: "review-user-structural-change-2", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "Review was interrupted." + )) + ), + ] + ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init( + id: "thread-review-structural-change", + turns: [outerTurn, companionTurn] + ), + phase: .terminal(turnID: companionTurnID, disposition: .completed) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text == reviewOutput) + + let interveningTurn = CodexTurnSnapshot( + id: interveningTurnID, + state: .completed, + items: [.init( + id: "command-structural-change", + kind: .commandExecution, + content: .command(.init( + command: "/usr/bin/true", + exitCode: 0, + status: .completed + )) + )] + ) + let inserted = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnInserted(interveningTurn, index: 1)) + )) + #expect(inserted?.sourceDocument?.text == "\(reviewOutput)\n\n$ /usr/bin/true") + + let removed = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.turnRemoved(id: interveningTurnID)) + )) + #expect(removed?.sourceDocument?.text == reviewOutput) + } + + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAfterTextAppend() + throws + { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let outerTurnID = CodexTurnID(rawValue: "outer-review-text-append") + let companionTurnID = CodexTurnID(rawValue: "companion-review-text-append") + let reviewOutput = "No actionable issues were identified." + let companion = CodexThreadItem( + id: "review_rollout_assistant", + kind: .agentMessage, + content: .message(.init( + id: "review_rollout_assistant", + role: .assistant, + text: "No actionable issues were" + )) + ) + let thread = CodexThreadSnapshot( + id: "thread-review-text-append", + turns: [ + .init( + id: outerTurnID, + state: .completed, + items: [.init( + id: "review-output-append", + kind: .exitedReviewMode, + content: .log(reviewOutput) + )] + ), .init( - id: secondTurnID, - status: .running, + id: companionTurnID, + state: .inProgress, items: [ - .init( - id: "reasoning-empty", - kind: .reasoning, - content: .reasoning(.empty) + .init( + id: "review-user-append-1", + kind: .userMessage, + content: .message(.init( + id: "review-user-append-1", + role: .user, + text: "Review the current changes." + )) + ), + .init( + id: "review-user-append-2", + kind: .userMessage, + content: .message(.init( + id: "review-user-append-2", + role: .user, + text: "Review the current changes." + )) ), + companion, ] ), ] ) + let initial = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: thread, + phase: .running(turnID: companionTurnID) + ), reason: .initial) + )) + #expect(initial?.sourceDocument?.text == reviewOutput) + #expect(initial?.sourceDocument?.blocks.count == 1) + + let completed = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.itemTextAppended( + .init(item: companion, turnID: companionTurnID), + delta: " identified." + )) + )) - let initialChange = projection.applyBaseline( - from: initialChat, - chatCreatedAt: nil, - chatUpdatedAt: nil + #expect(completed?.sourceDocument?.text == reviewOutput) + #expect(completed?.sourceDocument?.blocks.count == 1) + } + + @Test func codexChatSourceProjectionAppliesPhaseOnlyToStatusDependentBlocks() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let turnID = CodexTurnID(rawValue: "turn-phase") + let command = CodexThreadItem( + id: "command-phase", + kind: .commandExecution, + content: .command(.init(command: "/usr/bin/true")) ) - let updatedChange = projection.apply( - .itemInserted(id: "reasoning-empty", turnID: secondTurnID), - in: updatedChat, - chatCreatedAt: nil, - chatUpdatedAt: nil + let message = CodexThreadItem( + id: "message-phase", + kind: .agentMessage, + content: .message(.init( + id: "message-phase", + role: .assistant, + text: "Still visible" + )) ) + let thread = CodexThreadSnapshot( + id: "thread-phase", + status: .active(activeFlags: []), + turns: [.init(id: turnID, state: .inProgress, items: [command, message])] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: thread, + phase: .running(turnID: turnID) + ), reason: .initial) + )) - #expect(initialChange?.sourceDocument?.text == "Existing review log") - guard case .update(let document) = updatedChange else { - Issue.record("Expected the existing chat transcript to stay rendered while a new empty turn starts.") - return - } - #expect(document.text == "Existing review log") - #expect(updatedChange?.allowsIncrementalRender == true) + let threadStatus = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.statusChanged(.idle)) + )) + let unchangedCommand = try #require( + threadStatus?.sourceDocument?.blocks.first { $0.kind == .command } + ) + #expect(unchangedCommand.metadata?.status == CodexTurnStatus.inProgress.rawValue) + + let terminalPhase = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.phaseChanged(.terminal( + turnID: turnID, + disposition: .failed + ))) + )) + let failedCommand = try #require( + terminalPhase?.sourceDocument?.blocks.first { $0.kind == .command } + ) + #expect(failedCommand.metadata?.status == CodexTurnStatus.failed.rawValue) + #expect(terminalPhase?.sourceDocument?.text.contains("Still visible") == true) + } + + @Test func codexChatSourceProjectionTargetsInsertedAndRemovedTurns() throws { + var projection = ReviewMonitorCodexChatLogSourceProjection() + let firstTurnID = CodexTurnID(rawValue: "turn-first-targeted") + let secondTurnID = CodexTurnID(rawValue: "turn-second-targeted") + let first = CodexTurnSnapshot( + id: firstTurnID, + state: .completed, + items: [.init( + id: "first-targeted", + kind: .agentMessage, + content: .message(.init( + id: "first-targeted", + role: .assistant, + text: "First" + )) + )] + ) + _ = projection.apply(.init( + generation: 1, + sequence: 0, + payload: .snapshot(.init( + thread: .init(id: "thread-targeted-turns", turns: [first]), + phase: .terminal(turnID: firstTurnID, disposition: .completed) + ), reason: .initial) + )) + + let second = CodexTurnSnapshot( + id: secondTurnID, + state: .completed, + items: [.init( + id: "second-targeted", + kind: .agentMessage, + content: .message(.init( + id: "second-targeted", + role: .assistant, + text: "Second" + )) + )] + ) + let inserted = projection.apply(.init( + generation: 1, + sequence: 1, + payload: .update(.turnInserted(second, index: 1)) + )) + #expect(inserted?.sourceDocument?.text == "First\n\nSecond") + + let removed = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.turnRemoved(id: firstTurnID)) + )) + #expect(removed?.sourceDocument?.text == "Second") } @Test func codexChatRendersThreadAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: "chat-thread")) + try await runtime.transport.enqueueThreadResume(try makeChatDetailStoredThread(id: "chat-thread")) try await runtime.transport.enqueueThreadRead( - .init( + try makeChatDetailStoredThread( id: "chat-thread", - turns: [ - .init( - id: "turn-1", - status: .running, - items: [ - .init( - id: "message-1", - kind: .agentMessage, - content: .message( - .init( - id: "message-1", - role: .assistant, - phase: .finalAnswer, - text: "Generic chat snapshot" - )) - ) - ] - ) - ] - )) + turnID: "turn-1", + state: .inProgress, + messageID: "message-1", + message: "Generic chat snapshot", + phase: .finalAnswer + ) + ) let store = CodexReviewStore.makePreviewStore() let uiState = ReviewMonitorUIState(auth: store.auth) @@ -1171,17 +2314,13 @@ struct ReviewMonitorCodexChatDetailTests { } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) - try await runtime.transport.emitServerNotification( - method: "item/updated", - params: ThreadItemParams( - threadID: "chat-thread", - turnID: "turn-1", - item: .init( - id: "message-1", - type: "agentMessage", - text: "Generic chat stream update", - phase: "final_answer" - ) + try await runtime.notificationEmitter.emitItemCompleted( + threadID: "chat-thread", + turnID: "turn-1", + item: .agentMessage( + id: "message-1", + text: "Generic chat stream update", + phase: .finalAnswer ) ) @@ -1192,7 +2331,7 @@ struct ReviewMonitorCodexChatDetailTests { snapshot.log.contains("Generic chat stream update") } #expect(updatedSnapshot.log.contains("Generic chat snapshot") == false) - #expect(await runtime.transport.recordedRequests(method: "thread/resume").count == 1) + #expect(await runtime.transport.recordedRequests(for: .threadResume).count == 1) } private func selectChat( @@ -1203,43 +2342,88 @@ struct ReviewMonitorCodexChatDetailTests { uiState.selection = .chat(chatID) } - private func makeProjectionChat( - threadID: CodexThreadID = CodexThreadID(rawValue: "review-thread"), - turns: [CodexTurnSnapshot] - ) async throws -> CodexChat { - let runtime = try await CodexAppServerTestRuntime.start() - let modelContext = CodexModelContainer(appServer: runtime.server).mainContext - try await runtime.transport.enqueueThreadResume(.init(id: threadID)) - try await runtime.transport.enqueueThreadRead( - .init( - id: threadID, - turns: turns - )) - let chat = modelContext.model(for: threadID) - try await modelContext.refresh(chat) - return chat - } - private func rawPayload(type: String) -> Data { Data("{\"type\":\"\(type)\"}".utf8) } } -private struct ThreadItemParams: Encodable, Sendable { - var threadID: String - var turnID: String - var item: Item +private func makeChatDetailStoredThread( + id: CodexThreadID, + turns: [CodexAppServerTestTurn] = [] +) throws -> CodexAppServerTestStoredThread { + let workspace = URL( + fileURLWithPath: "/tmp/\(id.rawValue)", + isDirectory: true + ) + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + preview: id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 2), + status: .idle, + ephemeral: false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "chat-detail-\(id.rawValue)", + cliVersion: "review-ui-tests", + source: .appServer + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) +} - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case item - } +private func makeChatDetailTurn( + id: CodexTurnID, + state: CodexTurnSnapshot.State, + items: [CodexAppServerTestItem] +) throws -> CodexAppServerTestTurn { + try .init( + snapshot: .init( + id: id, + state: state, + items: items.map(\.domainProjection) + ), + items: items + ) +} - struct Item: Encodable, Sendable { - var id: String - var type: String - var text: String? - var phase: String? - } +private func makeChatDetailStoredThread( + id: CodexThreadID, + turnID: CodexTurnID, + state: CodexTurnSnapshot.State, + messageID: String, + message: String, + phase: CodexMessagePhase? = nil +) throws -> CodexAppServerTestStoredThread { + let item = try CodexAppServerTestItem.agentMessage( + id: messageID, + text: message, + phase: phase + ) + let turn = try makeChatDetailTurn( + id: turnID, + state: state, + items: [item] + ) + return try makeChatDetailStoredThread(id: id, turns: [turn]) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 456aeae4..e37ff323 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -1,5 +1,6 @@ import CodexAppServerKitTesting -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation import Testing @testable import ReviewUI @@ -18,18 +19,16 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { try await runtime.transport.enqueueThreadList( .init( threads: [ - .init( + try makeTitleResolverStoredThread( id: appThreadID, workspace: app, name: "App review", - sourceKind: .subAgentReview, updatedAt: Date(timeIntervalSince1970: 3_000) ), - .init( + try makeTitleResolverStoredThread( id: "thread-tools", workspace: tools, name: "Tools review", - sourceKind: .subAgentReview, updatedAt: Date(timeIntervalSince1970: 2_000) ), ] @@ -38,7 +37,7 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - let appWorkspace = try #require(context.model(for: workspaceID(for: app))) + let appWorkspace = try #require(context.registeredModel(for: workspaceID(for: app))) let workspaceGroup = try #require(appWorkspace.workspaceGroup) let appPath = app.standardizedFileURL.resolvingSymlinksInPath().path @@ -64,11 +63,10 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { try await runtime.transport.enqueueThreadList( .init( threads: [ - .init( + try makeTitleResolverStoredThread( id: "thread-repo", workspace: repo, name: "Repo review", - sourceKind: .subAgentReview, updatedAt: Date(timeIntervalSince1970: 1_000) ) ] @@ -77,7 +75,7 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { try await loadReviewChats(in: context) let resolver = ReviewMonitorCodexSelectionTitleResolver(modelContext: context) - let workspace = try #require(context.model(for: workspaceID(for: repo))) + let workspace = try #require(context.registeredModel(for: workspaceID(for: repo))) let workspaceGroup = try #require(workspace.workspaceGroup) let repoPath = repo.standardizedFileURL.resolvingSymlinksInPath().path @@ -89,19 +87,23 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { )) } - @Test func resolvesUncategorizedChatButDoesNotTreatUnknownChatAsLoaded() async throws { + @Test func resolvesChatOutsideGitRepositoryButDoesNotTreatUnknownChatAsLoaded() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let floatingThreadID = CodexThreadID(rawValue: "thread-floating") + let workspace = try makeTitleResolverDirectory( + "Floating", + in: FileManager.default.temporaryDirectory + ) try await runtime.transport.enqueueThreadList( .init( threads: [ - .init( + try makeTitleResolverStoredThread( id: floatingThreadID, + workspace: workspace, name: "Floating review", preview: "Uncategorized preview", - sourceKind: .subAgentReview, updatedAt: Date(timeIntervalSince1970: 1_000) ) ] @@ -114,7 +116,7 @@ struct ReviewMonitorCodexSelectionTitleResolverTests { resolver.titlePresentation(for: .chat(floatingThreadID)) == ReviewMonitorCodexSelectionTitlePresentation( title: "Floating review", - subtitle: "" + subtitle: workspace.standardizedFileURL.resolvingSymlinksInPath().path )) #expect( resolver.titlePresentation(for: .chat(CodexThreadID(rawValue: "thread-missing"))) == nil @@ -142,7 +144,7 @@ private func loadReviewChats(in context: CodexModelContext) async throws { let results = context.fetchedResults( for: CodexFetchDescriptor( predicate: sourceKindChatPredicate([.subAgentReview]), - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + sortBy: [CodexSortDescriptor(\.updatedAt, order: .reverse)] ), sectionedBy: .workspaceGroup ) @@ -151,7 +153,9 @@ private func loadReviewChats(in context: CodexModelContext) async throws { private func sourceKindChatPredicate(_ sourceKinds: [CodexThreadSourceKind]) -> Predicate { #Predicate { chat in - chat.sourceKind != nil && sourceKinds.contains(chat.sourceKind!) + chat.isArchived == false + && chat.sourceKind != nil + && sourceKinds.contains(chat.sourceKind!) } } @@ -159,6 +163,52 @@ private func workspaceID(for url: URL) -> CodexWorkspaceID { CodexWorkspaceID(rawValue: url.standardizedFileURL.resolvingSymlinksInPath().path) } +private func makeTitleResolverStoredThread( + id: CodexThreadID, + workspace: URL, + name: String, + preview: String? = nil, + updatedAt: Date +) throws -> CodexAppServerTestStoredThread { + let source = CodexAppServerTestSessionSource.subAgentReview + return try .init( + snapshot: .init( + id: id, + workspace: workspace, + name: name, + preview: preview ?? name, + modelProvider: "openai", + sourceKind: source.sourceKind, + createdAt: updatedAt, + updatedAt: updatedAt, + status: .idle, + ephemeral: false, + turns: [] + ), + turns: [], + metadata: .init( + sessionID: "title-resolver-\(id.rawValue)", + cliVersion: "codex-review-kit-tests", + source: source + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: workspace, + runtimeWorkspaceRoots: [workspace], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) +} + private func makeTitleResolverDirectory(_ name: String, in parent: URL) throws -> URL { let url = parent.appendingPathComponent(name, isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 4c1ec232..ed797190 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -1,5 +1,6 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexAppServerKitTesting import Foundation import Testing @@ -9,7 +10,7 @@ import Testing @Suite("ReviewMonitor Codex sidebar results") @MainActor struct ReviewMonitorCodexSidebarResultsTests { - @Test func buildsFlatSidebarSectionsFromCodexFetchedResultsController() async throws { + @Test func buildsFlatSidebarSectionsFromCodexFetchedResults() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -23,37 +24,39 @@ struct ReviewMonitorCodexSidebarResultsTests { id: "thread-app", workspace: app, name: "App chat", - updatedAt: Date(timeIntervalSince1970: 3_000) + updatedAt: Date(timeIntervalSince1970: 3_000), + recencyAt: Date(timeIntervalSince1970: 3_000) ), .init( id: "thread-tools", workspace: tools, name: "Tools chat", - updatedAt: Date(timeIntervalSince1970: 2_000) + updatedAt: Date(timeIntervalSince1970: 2_000), + recencyAt: Date(timeIntervalSince1970: 2_000) ), ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - let section = try #require(controller.sections.first) + let section = try #require(results.sections.first) let sectionWorkspaceGroupID = try #require(section.sidebarWorkspaceGroupID) let appWorkspace = try #require(section.workspaces.first) let appChat = try #require(section.chats(in: appWorkspace.id).first) let resolvedAppPath = app.standardizedFileURL.resolvingSymlinksInPath().path let resolvedToolsPath = tools.standardizedFileURL.resolvingSymlinksInPath().path - #expect(controller.sections.count == 1) + #expect(results.sections.count == 1) #expect(section.displayTitle == repo.lastPathComponent) #expect(section.workspaces.map(\.url.path) == [resolvedAppPath, resolvedToolsPath]) #expect(section.workspaces.map(\.name) == ["App", "Tools"]) #expect(section.items.map(\.title) == ["App chat", "Tools chat"]) - #expect(appChat === controller.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) + #expect(appChat === results.items.first { $0.id == CodexThreadID(rawValue: "thread-app") }) #expect(appChat.workspace?.url.path == resolvedAppPath) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: controller.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let outlineSection = try #require(tree.roots.first) let outlineAppChat = try #require(tree.node(rowID: .chat(appChat.id))) @@ -69,7 +72,7 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(outlineAppChat.item == .chat(appChat.id)) #expect(outlineAppChat.selectionID == .chat(appChat.id)) #expect( - controller.sections.rowIDs.map(\.rawValue) == [ + results.sections.rowIDs.map(\.rawValue) == [ "workspaceGroup:\(sectionWorkspaceGroupID.rawValue)", "chat:thread-app", "chat:thread-tools", @@ -103,9 +106,9 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() - let sections = controller.sections + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() + let sections = results.sections let originalWorkspace = try #require(sections.first?.workspaces.first) let filteredWorkspace = try #require(sections.filtered(by: .running).first?.workspaces.first) @@ -146,11 +149,11 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - #expect(controller.sections.count == 1) - #expect(controller.sections.filtered(by: .running).isEmpty) + #expect(results.sections.count == 1) + #expect(results.sections.filtered(by: .running).isEmpty) } @Test func defaultCodexSidebarDescriptorUsesDedicatedHomeWithoutSourceFiltering() async throws { @@ -159,57 +162,64 @@ struct ReviewMonitorCodexSidebarResultsTests { try await runtime.transport.enqueueThreadList(.init(threads: [])) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - let request = try #require(await runtime.transport.recordedRequests(method: "thread/list").first) - let params = try request.decodeParams(ThreadListParams.self) - #expect(params.archived == false) - #expect(params.sourceKinds == nil) + let request = try #require(await runtime.transport.recordedRequests(for: .threadList).first) + guard case .threadList(let query) = request.request else { + Issue.record("Expected a thread-list request.") + return + } + #expect(query.archived == false) + #expect(query.sourceKinds == nil) } - @Test func sidebarIncludesUncategorizedChatsWithStableRowIDs() async throws { + @Test func sidebarIncludesCanonicalWorkspaceChatsWithStableRowIDs() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext + let repo = try makeGitRepository() try await runtime.transport.enqueueThreadList( .init( threads: [ .init( - id: "thread-uncategorized", - name: "Floating review", - preview: "Uncategorized preview", + id: "thread-workspace", + workspace: repo, + name: "Workspace review", + preview: "Workspace preview", updatedAt: Date(timeIntervalSince1970: 4_000) ) ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() - - let section = try #require(controller.sections.first) - let chat = try #require(section.uncategorizedChats.first) + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() - #expect(section.sidebarWorkspaceGroupID == nil) - #expect(section.workspaces.isEmpty) - #expect(chat.id == CodexThreadID(rawValue: "thread-uncategorized")) - #expect(chat.title == "Floating review") - #expect(chat.preview == "Uncategorized preview") - #expect(chat.workspace == nil) + let section = try #require(results.sections.first) + let workspaceGroupID = try #require(section.sidebarWorkspaceGroupID) + let workspace = try #require(section.workspaces.first) + let chat = try #require(section.items.first) + + #expect(section.uncategorizedChats.isEmpty) + #expect(workspace.url.path == repo.standardizedFileURL.resolvingSymlinksInPath().path) + #expect(chat.id == CodexThreadID(rawValue: "thread-workspace")) + #expect(chat.title == "Workspace review") + #expect(chat.preview == "Workspace preview") + #expect(chat.workspace === workspace) #expect( section.rowIDs.map(\.rawValue) == [ - "section:unknown:unknown", - "chat:thread-uncategorized", + "workspaceGroup:\(workspaceGroupID.rawValue)", + "chat:thread-workspace", ]) let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: controller.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let outlineSection = try #require(tree.roots.first) let outlineChat = try #require(tree.node(rowID: .chat(chat.id))) - #expect(section.rowID == .section(.unknown("unknown"))) - #expect(outlineSection.item == .section(.unknown("unknown"))) - #expect(outlineSection.selectionID == nil) - #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-uncategorized"]) + #expect(section.rowID == .workspaceGroup(workspaceGroupID)) + #expect(outlineSection.item == .workspaceGroup(workspaceGroupID)) + #expect(outlineSection.selectionID == .workspaceGroup(workspaceGroupID)) + #expect(outlineSection.children.map(\.rowID.rawValue) == ["chat:thread-workspace"]) #expect(outlineChat.selectionID == .chat(chat.id)) #expect(outlineChat.isExpandable == false) } @@ -232,15 +242,15 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() let tree = ReviewMonitorCodexSidebarOutlineTree() - #expect(tree.apply(sections: controller.sections).topologyChanged) + #expect(tree.apply(sections: results.sections).topologyChanged) let root = try #require(tree.roots.first) let chatNode = try #require(tree.node(rowID: .chat(threadID))) - try await runtime.transport.enqueueThreadResume(.init(id: threadID)) + try await runtime.transport.enqueueThreadResume(.init(id: threadID, workspace: repo)) try await runtime.transport.enqueueThreadRead( .init( id: threadID, @@ -250,11 +260,11 @@ struct ReviewMonitorCodexSidebarResultsTests { )) try await context.refresh(context.model(for: threadID), includeTurns: false) - #expect(tree.apply(sections: controller.sections).topologyChanged == false) + #expect(tree.apply(sections: results.sections).topologyChanged == false) #expect(tree.roots.first === root) #expect(tree.node(rowID: .chat(threadID)) === chatNode) #expect(chatNode.item == .chat(threadID)) - #expect(controller.sections.chat(id: threadID)?.title == "Updated review") + #expect(results.sections.chat(id: threadID)?.title == "Updated review") #expect(root.children.map(\.rowID) == [.chat(threadID)]) #expect(root.children.first === chatNode) } @@ -286,16 +296,16 @@ struct ReviewMonitorCodexSidebarResultsTests { ] )) - let controller = makeCodexSidebarFetchedResultsController(context: context) - try await controller.performFetch() - let section = try #require(controller.sections.filtered(by: .running).first) + let results = makeCodexSidebarFetchedResults(context: context) + try await results.performFetch() + let section = try #require(results.sections.filtered(by: .running).first) let workspace = try #require(section.workspaces.first) #expect(section.chats(in: workspace.id).map(\.id) == [runningThreadID]) #expect(section.chats(in: workspace.id).contains { $0.id == idleThreadID } == false) } - @Test func sidebarViewControllerInstallsCodexSidebarFetchedResultsControllerFromModelContext() async throws { + @Test func sidebarViewControllerInstallsCodexSidebarFetchedResultsFromModelContext() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let repo = try makeGitRepository() @@ -370,7 +380,9 @@ struct ReviewMonitorCodexSidebarResultsTests { id: "run-backed-record", cwd: repo.path, targetSummary: "Run-backed review row", + attemptID: "run-backed-attempt", threadID: hiddenRunThreadID.rawValue, + reviewThreadID: hiddenRunThreadID.rawValue, turnID: "run-backed-turn", status: .running, startedAt: Date(timeIntervalSince1970: 4_000), @@ -619,7 +631,7 @@ struct ReviewMonitorCodexSidebarResultsTests { let fullReloadCountBeforeContentUpdate = sidebar.sidebarFullReloadCountForTesting let chat = context.model(for: threadID) let chatIdentityBeforeContentUpdate = ObjectIdentifier(chat) - try await runtime.transport.enqueueThreadResume(.init(id: threadID)) + try await runtime.transport.enqueueThreadResume(.init(id: threadID, workspace: repo)) try await runtime.transport.enqueueThreadRead( .init( id: threadID, @@ -658,13 +670,15 @@ struct ReviewMonitorCodexSidebarResultsTests { id: "thread-first-repo", workspace: firstRepo, name: "First repo review", - updatedAt: Date(timeIntervalSince1970: 5_000) + updatedAt: Date(timeIntervalSince1970: 5_000), + recencyAt: Date(timeIntervalSince1970: 5_000) ), .init( id: "thread-second-repo", workspace: secondRepo, name: "Second repo review", - updatedAt: Date(timeIntervalSince1970: 4_000) + updatedAt: Date(timeIntervalSince1970: 4_000), + recencyAt: Date(timeIntervalSince1970: 4_000) ), ] )) @@ -707,10 +721,11 @@ struct ReviewMonitorCodexSidebarResultsTests { #expect(sidebar.sidebarFullReloadCountForTesting == fullReloadCountBeforeReorder) } - @Test func sidebarViewControllerRejectsWorkspaceGroupDropsAcrossSectionRows() async throws { + @Test func sidebarViewControllerReordersAcrossCanonicalWorkspaceGroupRows() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let leadingRepo = try makeGitRepository() + let middleRepo = try makeGitRepository() let firstRepo = try makeGitRepository() let secondRepo = try makeGitRepository() @@ -721,24 +736,29 @@ struct ReviewMonitorCodexSidebarResultsTests { id: "thread-leading-repo", workspace: leadingRepo, name: "Leading repo review", - updatedAt: Date(timeIntervalSince1970: 7_000) + updatedAt: Date(timeIntervalSince1970: 7_000), + recencyAt: Date(timeIntervalSince1970: 7_000) ), .init( - id: "thread-uncategorized", - name: "Uncategorized review", - updatedAt: Date(timeIntervalSince1970: 6_000) + id: "thread-middle-repo", + workspace: middleRepo, + name: "Middle repo review", + updatedAt: Date(timeIntervalSince1970: 6_000), + recencyAt: Date(timeIntervalSince1970: 6_000) ), .init( id: "thread-first-repo", workspace: firstRepo, name: "First repo review", - updatedAt: Date(timeIntervalSince1970: 5_000) + updatedAt: Date(timeIntervalSince1970: 5_000), + recencyAt: Date(timeIntervalSince1970: 5_000) ), .init( id: "thread-second-repo", workspace: secondRepo, name: "Second repo review", - updatedAt: Date(timeIntervalSince1970: 4_000) + updatedAt: Date(timeIntervalSince1970: 4_000), + recencyAt: Date(timeIntervalSince1970: 4_000) ), ] )) @@ -756,39 +776,50 @@ struct ReviewMonitorCodexSidebarResultsTests { try await waitForCondition { sidebar.codexSidebarRootTitlesForTesting == [ leadingRepo.lastPathComponent, - "Unknown", + middleRepo.lastPathComponent, firstRepo.lastPathComponent, secondRepo.lastPathComponent, ] } let sections = sidebar.codexSidebarSectionsForTesting - let sectionRow = try #require(sections.first { $0.sidebarWorkspaceGroupID == nil }) - let workspaceGroupSections = sections.filter { $0.sidebarWorkspaceGroupID != nil } - let leadingSection = try #require(workspaceGroupSections.first) - let firstSection = try #require(workspaceGroupSections.dropFirst().first) - let secondSection = try #require(workspaceGroupSections.dropFirst(2).first) + #expect(sections.allSatisfy { $0.sidebarWorkspaceGroupID != nil }) + let leadingSection = try #require(sections.first) + let middleSection = try #require(sections.dropFirst().first) + let firstSection = try #require(sections.dropFirst(2).first) + let secondSection = try #require(sections.dropFirst(3).first) let leadingWorkspaceGroupID = try #require(leadingSection.sidebarWorkspaceGroupID) + let middleWorkspaceGroupID = try #require(middleSection.sidebarWorkspaceGroupID) let firstWorkspaceGroupID = try #require(firstSection.sidebarWorkspaceGroupID) let secondWorkspaceGroupID = try #require(secondSection.sidebarWorkspaceGroupID) - #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: sectionRow.rowID) == false) - #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: firstWorkspaceGroupID, toIndex: 1) == false) - #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: secondWorkspaceGroupID, toIndex: 2)) + for section in sections { + #expect(sidebar.codexSidebarCanStartDragForTesting(rowID: section.rowID)) + } + #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: firstWorkspaceGroupID, toIndex: 1)) #expect( sidebar.codexSidebarRootTitlesForTesting == [ leadingRepo.lastPathComponent, - "Unknown", + firstRepo.lastPathComponent, + middleRepo.lastPathComponent, secondRepo.lastPathComponent, + ]) + #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: leadingWorkspaceGroupID, toIndex: 4)) + #expect( + sidebar.codexSidebarRootTitlesForTesting == [ firstRepo.lastPathComponent, + middleRepo.lastPathComponent, + secondRepo.lastPathComponent, + leadingRepo.lastPathComponent, ]) - #expect(sidebar.performCodexWorkspaceGroupDropForTesting(id: leadingWorkspaceGroupID, toIndex: 4) == false) + #expect(Set([leadingWorkspaceGroupID, middleWorkspaceGroupID, firstWorkspaceGroupID, secondWorkspaceGroupID]).count == 4) } @Test func sidebarViewControllerReordersWorkspaceGroupsAcrossFilteredOutSectionRows() async throws { let runtime = try await CodexAppServerTestRuntime.start() let context = CodexModelContainer(appServer: runtime.server).mainContext let leadingRepo = try makeGitRepository() + let middleRepo = try makeGitRepository() let firstRepo = try makeGitRepository() let secondRepo = try makeGitRepository() @@ -800,12 +831,15 @@ struct ReviewMonitorCodexSidebarResultsTests { workspace: leadingRepo, name: "Leading repo review", updatedAt: Date(timeIntervalSince1970: 7_000), + recencyAt: Date(timeIntervalSince1970: 7_000), status: .active(activeFlags: []) ), .init( id: "thread-uncategorized", + workspace: middleRepo, name: "Uncategorized review", updatedAt: Date(timeIntervalSince1970: 6_000), + recencyAt: Date(timeIntervalSince1970: 6_000), status: .idle ), .init( @@ -813,6 +847,7 @@ struct ReviewMonitorCodexSidebarResultsTests { workspace: firstRepo, name: "First repo review", updatedAt: Date(timeIntervalSince1970: 5_000), + recencyAt: Date(timeIntervalSince1970: 5_000), status: .active(activeFlags: []) ), .init( @@ -820,6 +855,7 @@ struct ReviewMonitorCodexSidebarResultsTests { workspace: secondRepo, name: "Second repo review", updatedAt: Date(timeIntervalSince1970: 4_000), + recencyAt: Date(timeIntervalSince1970: 4_000), status: .active(activeFlags: []) ), ] @@ -873,13 +909,15 @@ struct ReviewMonitorCodexSidebarResultsTests { id: firstThreadID, workspace: repo, name: "First review", - updatedAt: Date(timeIntervalSince1970: 5_000) + updatedAt: Date(timeIntervalSince1970: 5_000), + recencyAt: Date(timeIntervalSince1970: 5_000) ), .init( id: secondThreadID, workspace: repo, name: "Second review", - updatedAt: Date(timeIntervalSince1970: 4_000) + updatedAt: Date(timeIntervalSince1970: 4_000), + recencyAt: Date(timeIntervalSince1970: 4_000) ), ] )) @@ -1110,16 +1148,11 @@ struct ReviewMonitorCodexSidebarResultsTests { } } -private struct ThreadListParams: Decodable { - var archived: Bool? - var sourceKinds: [String]? -} - @MainActor -private func makeCodexSidebarFetchedResultsController( +private func makeCodexSidebarFetchedResults( context: CodexModelContext -) -> CodexFetchedResultsController { - context.fetchedResultsController( +) -> CodexFetchedResults { + context.fetchedResults( for: ReviewMonitorSidebarViewController.defaultCodexSidebarDescriptor, sectionedBy: .workspaceGroup ) @@ -1141,3 +1174,54 @@ private func makeGitRepository() throws -> URL { ) return repo } + +private extension CodexAppServerTestStoredThread { + init( + id: CodexThreadID, + workspace: URL, + name: String? = nil, + preview: String? = nil, + updatedAt: Date = Date(timeIntervalSince1970: 0), + recencyAt: Date? = nil, + status: CodexThreadStatus = .idle + ) throws { + let cwd = workspace + try self.init( + snapshot: .init( + id: id, + workspace: cwd, + name: name, + preview: preview ?? name ?? id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: updatedAt, + updatedAt: updatedAt, + recencyAt: recencyAt ?? updatedAt, + status: status, + ephemeral: false, + turns: [] + ), + turns: [], + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: .appServer + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: cwd, + runtimeWorkspaceRoots: [cwd], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) + } +} diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 8d8854bc..f635fa40 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -1,6 +1,7 @@ import AppKit import CodexAppServerKitTesting -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation import ObservationBridge import SwiftUI @@ -51,6 +52,35 @@ extension ReviewUITests { #expect(snapshot.log.isEmpty == false) } + @Test func previewRuntimeReleasesAfterContentSourceDeallocation() async throws { + weak var releasedModelSource: ReviewMonitorCodexModelSource? + do { + let previewContent = ReviewMonitorPreviewContent.makeContentSource() + let modelSource = previewContent.codexModelSource + releasedModelSource = modelSource + previewContent.startStreamingForTesting(interval: .seconds(60)) + try await waitForCondition { + modelSource.modelContext != nil + } + } + + try await waitForCondition { + releasedModelSource == nil + } + } + + @Test func previewFixtureRuntimeReleasesAfterStoreDeallocationAndPrune() { + weak var releasedRuntime: AnyObject? + do { + let store = CodexReviewStore.makePreviewStore() + releasedRuntime = installPreviewChatLogSourceForTesting(on: store, fixtures: []) + } + + let probeStore = CodexReviewStore.makePreviewStore() + #expect(previewRuntimeForTesting(on: probeStore) == nil) + #expect(releasedRuntime == nil) + } + @Test func previewContentViewControllerRendersSidebarFromFakeAppServer() async throws { let viewController = makeReviewMonitorPreviewContentViewControllerForPreview() @@ -102,6 +132,17 @@ extension ReviewUITests { isChatActive: true ) == .directChat ) + _ = try await advancePreviewStreamBeyondMCPFixture( + previewContent, + transport: transport, + selectedChatID: selectedChatID + ) + let remainingRunningChatID = CodexThreadID(rawValue: "preview-thread-0-1") + let remainingItemCount = try #require( + await previewContent.snapshotForTesting(chatID: remainingRunningChatID) + ).items.count + previewContent.startStreamingForTesting(interval: .milliseconds(1)) + defer { previewContent.cancelStreamingForTesting() } var presentedCancelItem = false var cancelItemWasEnabled = false @@ -126,13 +167,17 @@ extension ReviewUITests { try await withTestTimeout(.seconds(2)) { while true { let snapshot = await previewContent.snapshotForTesting(chatID: selectedChatID) - if snapshot?.turns?.last?.status == .cancelled { + if snapshot?.turns?.last?.state == .interrupted { break } try Task.checkCancellation() await Task.yield() } } + try await waitForCondition { + previewContent.observedTurnStateForTesting(chatID: selectedChatID) == .interrupted + && previewContent.turnCompletionNotificationCountForTesting() == 1 + } try await waitForCondition { sidebar.codexSidebarSectionsForTesting.chat(id: selectedChatID)?.status == .idle && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID)) == nil @@ -161,6 +206,20 @@ extension ReviewUITests { } #expect(presentedCancelItemAfterCancellation) #expect(cancelItemEnabledAfterCancellation == false) + + try await withTestTimeout(.seconds(2)) { + while await previewContent.snapshotForTesting(chatID: remainingRunningChatID)?.items.count + == remainingItemCount + { + try Task.checkCancellation() + await Task.yield() + } + } + let updatedRemainingSnapshot = try #require( + await previewContent.snapshotForTesting(chatID: remainingRunningChatID) + ) + #expect(updatedRemainingSnapshot.items.count > remainingItemCount) + await previewContent.stopStreamingForTesting() } @Test func inactiveChatContextMenuArchiveSkipsConfirmationAndRemovesSidebarChat() async throws { @@ -181,7 +240,7 @@ extension ReviewUITests { ) ] )) - try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await runtime.transport.enqueueSuccess(for: .threadArchive) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running) @@ -215,11 +274,11 @@ extension ReviewUITests { #expect(archiveMenuState.presented) #expect(archiveMenuState.enabled) - await runtime.transport.waitForRequest(method: "thread/archive") + await runtime.transport.waitForRequest(.threadArchive) try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == nil } - #expect(await runtime.transport.recordedRequests(method: "thread/archive").count == 1) + #expect(await runtime.transport.recordedRequests(for: .threadArchive).count == 1) #expect(confirmationRequestCount == 0) } @@ -277,7 +336,7 @@ extension ReviewUITests { try await waitForCondition { confirmedChatIDs == [chatID] } - #expect(await runtime.transport.recordedRequests(method: "thread/archive").isEmpty) + #expect(await runtime.transport.recordedRequests(for: .threadArchive).isEmpty) #expect(sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == "Rejected active archive chat") } @@ -299,7 +358,7 @@ extension ReviewUITests { ) ] )) - try await runtime.transport.enqueueEmpty(for: "thread/archive") + try await runtime.transport.enqueueSuccess(for: .threadArchive) let store = CodexReviewStore.makePreviewStore() store.loadForTesting(serverState: .running) @@ -333,11 +392,11 @@ extension ReviewUITests { #expect(archiveMenuState.presented) #expect(archiveMenuState.enabled) - await runtime.transport.waitForRequest(method: "thread/archive") + await runtime.transport.waitForRequest(.threadArchive) try await waitForCondition { sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(chatID)) == nil } - #expect(await runtime.transport.recordedRequests(method: "thread/archive").count == 1) + #expect(await runtime.transport.recordedRequests(for: .threadArchive).count == 1) #expect(confirmedChatIDs == [chatID]) } @@ -354,11 +413,24 @@ extension ReviewUITests { viewController.loadViewIfNeeded() viewController.view.layoutSubtreeIfNeeded() - let sidebar = viewController.splitViewControllerForTesting.sidebarViewControllerForTesting + let splitViewController = viewController.splitViewControllerForTesting + let sidebar = splitViewController.sidebarViewControllerForTesting + let transport = splitViewController.transportViewControllerForTesting try await waitForCondition { sidebar.sidebarKindForTesting == .chatList && sidebar.codexSidebarNodeTitleForTesting(rowID: .chat(selectedChatID)) != nil } + _ = try await advancePreviewStreamBeyondMCPFixture( + previewContent, + transport: transport, + selectedChatID: selectedChatID + ) + let remainingRunningChatID = CodexThreadID(rawValue: "preview-thread-0-1") + let remainingItemCount = try #require( + await previewContent.snapshotForTesting(chatID: remainingRunningChatID) + ).items.count + previewContent.startStreamingForTesting(interval: .milliseconds(1)) + defer { previewContent.cancelStreamingForTesting() } var confirmedChatIDs: [CodexThreadID] = [] sidebar.setChatArchiveConfirmationForTesting { chatID, _ in @@ -385,6 +457,20 @@ extension ReviewUITests { } #expect(await previewContent.archiveRequestCountForTesting() == 1) #expect(confirmedChatIDs == [selectedChatID]) + + try await withTestTimeout(.seconds(2)) { + while await previewContent.snapshotForTesting(chatID: remainingRunningChatID)?.items.count + == remainingItemCount + { + try Task.checkCancellation() + await Task.yield() + } + } + let updatedRemainingSnapshot = try #require( + await previewContent.snapshotForTesting(chatID: remainingRunningChatID) + ) + #expect(updatedRemainingSnapshot.items.count > remainingItemCount) + await previewContent.stopStreamingForTesting() } @Test func activeChatContextMenuCancelFallsBackWhenMatchingReviewRunIsTerminal() async throws { @@ -404,7 +490,7 @@ extension ReviewUITests { updatedAt: Date(timeIntervalSince1970: 5_000), status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] ) ] @@ -414,16 +500,18 @@ extension ReviewUITests { id: chatID, status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] )) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.enqueueSuccess(for: .turnInterrupt) let terminalRun = ReviewRunRecord.makeForTesting( id: "terminal-run", cwd: repo.path, targetSummary: "Terminal review run", + attemptID: "terminal-attempt", threadID: chatID.rawValue, + reviewThreadID: chatID.rawValue, turnID: "terminal-turn", status: .succeeded, startedAt: Date(timeIntervalSince1970: 4_000), @@ -471,8 +559,8 @@ extension ReviewUITests { #expect(presentedCancelItem) #expect(cancelItemWasEnabled) - await runtime.transport.waitForRequest(method: "turn/interrupt") - let interruptRequestCount = await runtime.transport.recordedRequests(method: "turn/interrupt").count + await runtime.transport.waitForRequest(.turnInterrupt) + let interruptRequestCount = await runtime.transport.recordedRequests(for: .turnInterrupt).count #expect(interruptRequestCount == 1) } @@ -493,7 +581,7 @@ extension ReviewUITests { updatedAt: Date(timeIntervalSince1970: 5_000), status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] ) ] @@ -503,16 +591,18 @@ extension ReviewUITests { id: chatID, status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] )) - try await runtime.transport.enqueueEmpty(for: "turn/interrupt") + try await runtime.transport.enqueueSuccess(for: .turnInterrupt) let terminalRun = ReviewRunRecord.makeForTesting( id: "a-terminal-run", cwd: repo.path, targetSummary: "Terminal review run", + attemptID: "terminal-attempt", threadID: chatID.rawValue, + reviewThreadID: chatID.rawValue, turnID: "terminal-review-turn", status: .succeeded, startedAt: Date(timeIntervalSince1970: 4_100), @@ -523,7 +613,9 @@ extension ReviewUITests { id: "z-pending-cancellation-run", cwd: repo.path, targetSummary: "Pending cancellation review run", + attemptID: "pending-cancellation-attempt", threadID: chatID.rawValue, + reviewThreadID: chatID.rawValue, turnID: "pending-review-turn", status: .running, cancellationRequested: true, @@ -571,8 +663,8 @@ extension ReviewUITests { // command is visible but disabled. #expect(cancelItemWasEnabled == false) try await Task.sleep(for: .milliseconds(100)) - let resumeRequestCount = await runtime.transport.recordedRequests(method: "thread/resume").count - let interruptRequestCount = await runtime.transport.recordedRequests(method: "turn/interrupt").count + let resumeRequestCount = await runtime.transport.recordedRequests(for: .threadResume).count + let interruptRequestCount = await runtime.transport.recordedRequests(for: .turnInterrupt).count #expect(resumeRequestCount == 0) #expect(interruptRequestCount == 0) } @@ -2016,6 +2108,15 @@ extension ReviewUITests { viewController.view.layoutSubtreeIfNeeded() transport.view.layoutSubtreeIfNeeded() + try await waitForCondition { + let visibleFragmentBounds = transport.logVisibleFragmentBoundsForTesting + let visibleBottomInViewport = + visibleFragmentBounds.maxY - transport.logVerticalScrollOffsetForTesting + let bottomGap = transport.logViewportHeightForTesting - visibleBottomInViewport + return abs(bottomGap) < 120 + && transport.isLogPinnedToBottomForTesting + && transport.logStaleFragmentViewCountForTesting == 0 + } let visibleFragmentBounds = transport.logVisibleFragmentBoundsForTesting let visibleBottomInViewport = visibleFragmentBounds.maxY - transport.logVerticalScrollOffsetForTesting let bottomGap = transport.logViewportHeightForTesting - visibleBottomInViewport @@ -2129,8 +2230,8 @@ extension ReviewUITests { #expect(appendedText.contains("delta/") == false) #expect(appendedText.count < 160) #expect(appendedItems.count == 1) - #expect(appendedItems.first?.kind.rawValue == "event") - #expect(appendedItems.first.map(diagnosticMessage)?.contains("preview-turn") == true) + #expect(appendedItems.first?.kind == .agentMessage) + #expect(appendedItems.first?.text?.contains("preview-turn") == true) } @Test func previewChatStreamUsesMixedLogKinds() async throws { @@ -2147,21 +2248,19 @@ extension ReviewUITests { let updatedSnapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) let appendedItems = Array(updatedSnapshot.items.dropFirst(initialItemCount)) let appendedKinds = appendedItems.map { $0.kind.rawValue } - #expect(appendedKinds.contains("event")) #expect(appendedKinds.contains("commandExecution")) #expect(appendedKinds.contains("mcpToolCall")) #expect(appendedKinds.contains("plan")) - #expect(appendedKinds.contains("contextCompaction")) #expect(appendedKinds.contains("reasoning")) #expect(appendedKinds.contains("agentMessage")) - #expect(Set(appendedKinds).count >= 6) + #expect(Set(appendedKinds).count >= 5) #expect(Set(appendedItems.map { $0.id }).count == appendedItems.count) let compactionItems = updatedSnapshot.items .dropFirst(initialItemCount) - .filter { $0.kind.rawValue == "contextCompaction" } + .filter { $0.id.contains("context-compaction") } let compactionItem = try #require(compactionItems.last) - #expect(contextCompactionTitle(compactionItem) == "Context automatically compacted") + #expect(compactionItem.text == "Context automatically compacted") let renderedLog = updatedSnapshot.items.compactMap { $0.text }.joined(separator: "\n") #expect(renderedLog.contains("Context automatically compacted")) #expect(renderedLog.contains("Automatically compacting context") == false) @@ -2177,7 +2276,7 @@ extension ReviewUITests { tick = await source.appendPreviewChatLogStreamTick(after: tick) var snapshot = try #require(await source.snapshotForTesting(chatID: runningChatID)) #expect(snapshot.items.count == initialItemCount + 1) - #expect(snapshot.items.last?.kind.rawValue == "event") + #expect(snapshot.items.last?.kind == .agentMessage) for _ in 0..<38 { tick = await source.appendPreviewChatLogStreamTick(after: tick) @@ -2227,6 +2326,70 @@ extension ReviewUITests { } +private extension CodexAppServerTestTurn { + init(id: CodexTurnID, state: CodexTurnSnapshot.State) throws { + try self.init( + snapshot: .init(id: id, state: state), + items: [] + ) + } +} + +private extension CodexAppServerTestStoredThread { + init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + updatedAt: Date = Date(timeIntervalSince1970: 0), + recencyAt: Date? = nil, + status: CodexThreadStatus = .idle, + turns: [CodexAppServerTestTurn] = [] + ) throws { + let cwd = workspace ?? URL( + fileURLWithPath: "/tmp/codex-review-ui-shell", + isDirectory: true + ) + try self.init( + snapshot: .init( + id: id, + workspace: cwd, + name: name, + preview: preview ?? name ?? id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: updatedAt, + updatedAt: updatedAt, + recencyAt: recencyAt ?? updatedAt, + status: status, + ephemeral: false, + turns: turns.map(\.snapshot) + ), + turns: turns, + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: .appServer + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: cwd, + runtimeWorkspaceRoots: [cwd], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) + } +} + @MainActor private func previewSelectedChatID(in store: CodexReviewStore) -> CodexThreadID? { _ = store @@ -2263,6 +2426,30 @@ private func performChatContextMenuItemForTesting( return (presented: presented, enabled: enabled) } +@MainActor +private func advancePreviewStreamBeyondMCPFixture( + _ previewContent: ReviewMonitorPreviewContentSource, + transport: ReviewMonitorTransportViewController, + selectedChatID: CodexThreadID +) async throws -> Int { + var tick = 0 + for _ in 0..<220 { + tick = await previewContent.appendPreviewChatLogStreamTick( + after: tick, + emitsNotifications: true + ) + } + + _ = try await awaitTransportRender( + transport, + expectedSelection: .chat(selectedChatID.rawValue), + timeout: .seconds(5) + ) { snapshot in + snapshot.log.contains("Checking whether") + } + return tick +} + @MainActor private func diagnosticMessage(_ item: CodexThreadItem) -> String { if case .diagnostic(let message) = item.content { @@ -2320,6 +2507,7 @@ private func renderDetailLogForShellLayoutTesting( } viewController.view.layoutSubtreeIfNeeded() transport.view.layoutSubtreeIfNeeded() + transport.scrollLogToBottomForTesting() } private func makeSidebarReviewChatFilterDefaultsForTesting() throws -> (defaults: UserDefaults, suiteName: String) { diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index c5e3c112..647e363b 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1,8 +1,9 @@ import AppKit import CodexAppServerKitTesting +import CodexAppServerKit +import CodexDataKit import Foundation import ObservationBridge -import CodexKit import CodexReviewKit import SwiftUI import Testing @@ -23,6 +24,60 @@ private extension CodexReviewAuthModel { } } +private extension CodexAppServerTestStoredThread { + init( + id: CodexThreadID, + workspace: URL? = nil, + name: String? = nil, + preview: String? = nil, + updatedAt: Date = Date(timeIntervalSince1970: 0), + recencyAt: Date? = nil, + status: CodexThreadStatus = .idle + ) throws { + let cwd = workspace ?? URL( + fileURLWithPath: "/tmp/codex-review-ui", + isDirectory: true + ) + try self.init( + snapshot: .init( + id: id, + workspace: cwd, + name: name, + preview: preview ?? name ?? id.rawValue, + modelProvider: "openai", + sourceKind: .appServer, + createdAt: updatedAt, + updatedAt: updatedAt, + recencyAt: recencyAt ?? updatedAt, + status: status, + ephemeral: false, + turns: [] + ), + turns: [], + metadata: .init( + sessionID: "session-\(id.rawValue)", + cliVersion: "codex-cli-test", + source: .appServer + ), + runtimeMetadata: .init( + model: "gpt-5", + modelProvider: "openai", + serviceTier: nil, + cwd: cwd, + runtimeWorkspaceRoots: [cwd], + instructionSources: [], + approvalPolicy: .never, + approvalsReviewer: .user, + sandbox: .dangerFullAccess, + activePermissionProfile: nil, + reasoningEffort: nil, + multiAgentMode: .explicitRequestOnly + ), + isArchived: false + ) + } +} + @Suite(.serialized) @MainActor struct ReviewUITests { @@ -835,10 +890,10 @@ struct ReviewUITests { text: "$ git diff --stat", metadata: .init( sourceType: "commandExecution", - status: "completed", + status: .completed, itemID: "cmd_1", command: "git diff --stat", - commandStatus: "completed" + commandStatus: .completed ) ), .init( @@ -847,10 +902,10 @@ struct ReviewUITests { text: "README.md | 1 +", metadata: .init( sourceType: "commandExecution", - status: "completed", + status: .completed, itemID: "cmd_1", command: "git diff --stat", - commandStatus: "completed" + commandStatus: .completed ) ), .init(kind: .agentMessage, text: "No correctness issues found."), @@ -931,7 +986,7 @@ struct ReviewUITests { kind: .command, groupID: "cmd-direct", text: "$ swift test", - metadata: .init(command: "swift test", commandStatus: "completed") + metadata: .init(command: "swift test", commandStatus: .completed) ), to: chat.chatID, turnID: chat.turnID @@ -941,7 +996,7 @@ struct ReviewUITests { kind: .commandOutput, groupID: "cmd-direct", text: "Tests passed", - metadata: .init(command: "swift test", exitCode: 0, commandStatus: "completed") + metadata: .init(command: "swift test", exitCode: 0, commandStatus: .completed) ), to: chat.chatID, turnID: chat.turnID @@ -1006,7 +1061,7 @@ struct ReviewUITests { kind: .commandOutput, groupID: "cmd-failed-direct", text: "Tests failed", - metadata: .init(command: "swift test", commandStatus: "failed") + metadata: .init(command: "swift test", commandStatus: .failed) ), to: chat.chatID, turnID: chat.turnID @@ -1062,7 +1117,7 @@ struct ReviewUITests { kind: .commandOutput, groupID: "cmd-running-direct", text: "Building...", - metadata: .init(command: "swift test", commandStatus: "running") + metadata: .init(command: "swift test", commandStatus: .inProgress) ), to: chat.chatID, turnID: chat.turnID @@ -1122,7 +1177,7 @@ struct ReviewUITests { text: "Sources/App.swift | 12 ++++++------", metadata: .init( title: "Updated Sources/App.swift", - commandStatus: "completed" + commandStatus: .completed ) ), to: chat.chatID, @@ -1147,7 +1202,7 @@ struct ReviewUITests { ) } - @Test func contextCompactionMarkerRendersAsVisibleLogTextWithoutCommandPanel() async throws { + @Test func contextCompactionMarkerRendersCanonicalVisibleTextWithoutCommandPanel() async throws { let chat = makeReviewChatFixtureForTesting( id: "chat-context-compaction-marker", cwd: "/tmp/workspace-alpha", @@ -1163,7 +1218,7 @@ struct ReviewUITests { text: "Automatically compacting context", metadata: .init( sourceType: "contextCompaction", - status: "inProgress", + status: .inProgress, itemID: "compact_1" ) ) @@ -1189,30 +1244,8 @@ struct ReviewUITests { in: transport, allowIncrementalUpdate: false ) - #expect(transport.displayedLogForTesting == "Automatically compacting context") - #expect(transport.logFindStringForTesting.contains("Automatically compacting context")) - #expect(transport.logCommandOutputPanelCountForTesting == 0) - - await appendChatLogEntryForTesting( - .init( - kind: .contextCompaction, - groupID: "compact_1", - replacesGroup: true, - text: "Context automatically compacted", - metadata: .init( - sourceType: "contextCompaction", - status: "completed", - itemID: "compact_1" - ) - ), - to: chat.chatID, - turnID: chat.turnID - ) - _ = try await awaitChatRenderForTesting(chat, in: transport) - - #expect(transport.displayedLogForTesting == "Context automatically compacted") - #expect(transport.displayedLogForTesting.contains("Automatically compacting context") == false) - #expect(transport.logFindStringForTesting.contains("Context automatically compacted")) + #expect(transport.displayedLogForTesting == "Context compaction") + #expect(transport.logFindStringForTesting.contains("Context compaction")) #expect(transport.logCommandOutputPanelCountForTesting == 0) } @@ -1223,10 +1256,10 @@ struct ReviewUITests { let commandMetadata = ReviewChatLogEntryForTesting.Metadata( sourceType: "command", title: "Ran command for 17s", - status: "succeeded", + status: .completed, command: "swift test", exitCode: 0, - commandStatus: "completed" + commandStatus: .completed ) let chat = makeReviewChatFixtureForTesting( id: "chat-command-output-panel", @@ -1401,14 +1434,14 @@ struct ReviewUITests { kind: .commandOutput, groupID: "cmd_1", text: firstOutput, - metadata: .init(sourceType: "command", title: "Ran swift test for 1s", status: "succeeded") + metadata: .init(sourceType: "command", title: "Ran swift test for 1s", status: .completed) ), .init(kind: .command, groupID: "cmd_2", text: "$ git diff"), .init( kind: .commandOutput, groupID: "cmd_2", text: secondOutput, - metadata: .init(sourceType: "command", title: "Ran git diff for 1s", status: "succeeded") + metadata: .init(sourceType: "command", title: "Ran git diff for 1s", status: .completed) ), ] ) @@ -1480,7 +1513,8 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - chat, + chatID: chat.chatID, + expectedLog: "Running swift test", in: transport, allowIncrementalUpdate: false ) @@ -1496,16 +1530,20 @@ struct ReviewUITests { metadata: .init( sourceType: "commandExecution", title: "Command output", - status: "succeeded", + status: .completed, command: "swift test", exitCode: 0, - commandStatus: "completed" + commandStatus: .completed ) ), to: chat.chatID, turnID: chat.turnID ) - _ = try await awaitChatRenderForTesting(chat, in: transport) + _ = try await awaitChatRenderForTesting( + chatID: chat.chatID, + expectedLog: "Ran swift test", + in: transport + ) #expect(transport.logCommandOutputPanelCountForTesting == 1) #expect(transport.displayedLogForTesting.contains("Ran swift test")) #expect(transport.displayedLogForTesting.contains("$ swift test") == false) @@ -2177,7 +2215,7 @@ struct ReviewUITests { } let activeChat = context.model(for: activeThreadID) - try await runtime.transport.enqueueEmpty(for: "thread/delete") + try await runtime.transport.enqueueSuccess(for: .threadDelete) try await activeChat.delete() try await waitForCondition { @@ -2365,9 +2403,10 @@ struct ReviewUITests { ) { $0.log == "Initial" } let wordGlowCount = transport.logWordGlowCountForTesting await previewRuntime.upsertPreviewItem( - id: "progress_1", - kind: CodexThreadItem.Kind(rawValue: "progress"), - content: .diagnostic("stream.tick 001"), + try CodexAppServerTestItem.agentMessage( + id: "progress_1", + text: "stream.tick 001" + ), to: chatID ) @@ -2661,11 +2700,11 @@ struct ReviewUITests { text: "$ git diff", metadata: .init( sourceType: "commandExecution", - status: "inProgress", + status: .inProgress, itemID: "cmd_1", command: "git diff", startedAt: startedAt, - commandStatus: "inProgress" + commandStatus: .inProgress ) ), to: chat.chatID, @@ -2686,14 +2725,14 @@ struct ReviewUITests { expectedLog: """ Need to inspect files. - Ran git diff + Ran git diff for 0s Inspecting details after the command starts. """, in: transport ) #expect(snapshot.log.contains("Need to inspect files.")) - #expect(snapshot.log.contains("Ran git diff")) + #expect(snapshot.log.contains("Ran git diff for 0s")) #expect(snapshot.log.contains("Inspecting details after the command starts.")) #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logReloadCountForTesting == reloadCount) @@ -2831,6 +2870,56 @@ struct ReviewUITests { #expect(visibleText.contains("11sReviewing JSONRPC requests") == false) } + @Test func completedCommandUsesLifecycleIntervalWhenReportedDurationIsInconsistent() throws { + let startedAt = Date(timeIntervalSince1970: 200) + var projection = ReviewMonitorLogDocumentProjection() + let source = projection.render(projectedBlocks: [ + .init( + id: .init("command_1"), + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd_1", + command: "git diff", + startedAt: startedAt, + completedAt: Date(timeIntervalSince1970: 205.327), + durationMs: 0, + commandStatus: "completed" + ) + ), + ]) + + let display = ReviewMonitorCommandOutputDisplayDocument.make(from: source) + let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: display.text) + #expect(visibleText == "Ran git diff for 5s") + } + + @Test func completedCommandWithoutTimingMetadataOmitsDuration() throws { + var projection = ReviewMonitorLogDocumentProjection() + let source = projection.render(projectedBlocks: [ + .init( + id: .init("command_1"), + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd_1", + command: "git diff", + commandStatus: "completed" + ) + ), + ]) + + let display = ReviewMonitorCommandOutputDisplayDocument.make(from: source) + let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: display.text) + #expect(visibleText == "Ran git diff") + } + @Test func sourceDocumentReplacementTargetsMetadataChangedBlock() throws { let startedAt = Date(timeIntervalSince1970: 200) var projection = ReviewMonitorLogDocumentProjection() @@ -3210,7 +3299,7 @@ struct ReviewUITests { kind: .command, groupID: "cmd_1", text: "$ git diff", - metadata: .init(command: "git diff", commandStatus: "running") + metadata: .init(command: "git diff", commandStatus: .inProgress) ), to: chat.chatID, turnID: chat.turnID @@ -3226,11 +3315,11 @@ struct ReviewUITests { ) let snapshot = try await awaitChatRenderForTesting(chat, in: transport) { - $0.log.contains("Ran git diff\n\nReviewing JSONRPC requests") + $0.log.contains("Ran git diff for 0s\n\nReviewing JSONRPC requests") } - #expect(snapshot.log.contains("Ran git diff\n\nReviewing JSONRPC requests")) + #expect(snapshot.log.contains("Ran git diff for 0s\n\nReviewing JSONRPC requests")) #expect(snapshot.log.contains("git diffReviewing JSONRPC requests") == false) - #expect(transport.displayedLogForTesting.contains("Ran git diff\n\nReviewing JSONRPC requests")) + #expect(transport.displayedLogForTesting.contains("Ran git diff for 0s\n\nReviewing JSONRPC requests")) #expect(transport.displayedLogForTesting.contains("git diffReviewing JSONRPC requests") == false) #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logReloadCountForTesting == reloadCount) @@ -3385,12 +3474,12 @@ struct ReviewUITests { turnID: chat.turnID ) _ = try await awaitChatRenderForTesting(chat, in: transport) { snapshot in - snapshot.log.contains("Command output") + snapshot.log.contains("Running Command") } #expect(transport.displayedLogForTesting.contains("- updated with longer replacement text")) - #expect(transport.displayedLogForTesting.contains("Command output")) - #expect(transport.displayedLogForTesting.contains("Command output - 1 line") == false) + #expect(transport.displayedLogForTesting.contains("Running Command")) + #expect(transport.displayedLogForTesting.contains("Running Command - 1 line") == false) #expect(transport.displayedLogForTesting.contains("hidden output") == false) #expect(transport.logAppendCountForTesting == appendCount + 1) #expect(transport.logReplaceCountForTesting == replaceCount) @@ -3425,9 +3514,10 @@ struct ReviewUITests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting await previewRuntime.upsertPreviewItem( - id: "fixture-log-\(chat.id)", - kind: .agentMessage, - content: .message(.init(id: "fixture-log-\(chat.id)", role: .assistant, text: "Initial log")), + try CodexAppServerTestItem.agentMessage( + id: "fixture-log-\(chat.id)", + text: "Initial log" + ), to: chat.chatID ) @@ -5634,7 +5724,8 @@ func makeReviewChatFixtureForTesting( updatedAt: status.isTerminal ? startedAt.addingTimeInterval(1) : startedAt, chatEntries: trimmedLogText.isEmpty ? [] - : [.init(kind: .agentMessage, groupID: "fixture-log-\(id)", text: trimmedLogText)] + : [.init(kind: .agentMessage, groupID: "fixture-log-\(id)", text: trimmedLogText)], + errorMessage: status == .failed ? summary : nil ) } @@ -5761,7 +5852,7 @@ struct TestAuthState: Equatable { if let progress { phase = .signingIn(progress) } else if let errorMessage { - phase = .failed(message: errorMessage) + phase = .failed(.runtime(message: errorMessage)) } else { phase = .signedOut } @@ -5806,10 +5897,10 @@ struct TestAuthState: Equatable { } var errorMessage: String? { - guard case .failed(let message) = phase else { + guard case .failed(let failure) = phase else { return nil } - return message + return failure.localizedDescription } } @@ -5958,7 +6049,7 @@ final class CountingStartBackend: PreviewCodexReviewStoreBackend { startCalls += 1 } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { isActive = false } @@ -5994,7 +6085,7 @@ final class AuthActionBackend: PreviewCodexReviewStoreBackend { isActive = true } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { isActive = false } @@ -6036,13 +6127,13 @@ final class FailingCancellationBackend: PreviewCodexReviewStoreBackend { ) async { } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { } override func waitUntilStopped() async {} override func interruptReview( - _: CodexReviewBackendModel.Review.Run, reason _: CodexReviewBackendModel.CancellationReason + _: ReviewAttempt, reason _: CodexReviewBackendModel.CancellationReason ) async throws { throw CodexReviewAPI.Error.io("Cancellation failed.") } @@ -6087,7 +6178,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { ) async { } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { } override func waitUntilStopped() async {} @@ -6097,7 +6188,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextRefresh { shouldBlockNextRefresh = false await blockedRefreshStartedGate.open() - await blockedRefreshResumeGate.wait() + try? await blockedRefreshResumeGate.wait() } return currentSettingsSnapshot } @@ -6127,7 +6218,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextModelUpdate { shouldBlockNextModelUpdate = false await blockedModelUpdateStartedGate.open() - await blockedModelUpdateResumeGate.wait() + try? await blockedModelUpdateResumeGate.wait() } } @@ -6140,7 +6231,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextReasoningUpdate { shouldBlockNextReasoningUpdate = false await blockedReasoningUpdateStartedGate.open() - await blockedReasoningUpdateResumeGate.wait() + try? await blockedReasoningUpdateResumeGate.wait() } } @@ -6156,7 +6247,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedRefreshToStart() async { - await blockedRefreshStartedGate.wait() + try? await blockedRefreshStartedGate.wait() } func resumeBlockedRefresh() async { @@ -6168,7 +6259,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedModelUpdateToStart() async { - await blockedModelUpdateStartedGate.wait() + try? await blockedModelUpdateStartedGate.wait() } func resumeBlockedModelUpdate() async { @@ -6180,7 +6271,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedReasoningUpdateToStart() async { - await blockedReasoningUpdateStartedGate.wait() + try? await blockedReasoningUpdateStartedGate.wait() } func resumeBlockedReasoningUpdate() async { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index de72f79e..e599fba6 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,6 @@ { "originHash" : "11d1673255c652381957935c0aa0fcc8f6cba52200a3e3e3c7296d58e6207f76", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "3f6216c01c91bf14737e6fe40c30efef7a5bbd04" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", @@ -75,10 +67,9 @@ { "identity" : "swift-sdk", "kind" : "remoteSourceControl", - "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "location" : "https://github.com/lynnswap/swift-sdk.git", "state" : { - "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", - "version" : "0.12.1" + "revision" : "fae7761fd5d257b24e1d9c49c6dc121e188e0d9b" } }, { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index a9aaf363..59a60ced 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -47,20 +47,20 @@ struct ReviewMonitorLaunchContext: Sendable { } enum ReviewMonitorLaunchEnvironment { - static let reviewModeKey = CodexReviewStoreTestEnvironment.reviewModeKey + static let reviewModeKey = "REVIEW_MONITOR_REVIEW_MODE" static let xctestConfigurationKey = "XCTestConfigurationFilePath" static let xctestBundlePathKey = "XCTestBundlePath" static let xcInjectBundleIntoKey = "XCInjectBundleInto" static let xctestSessionIdentifierKey = "XCTestSessionIdentifier" static let xcodeRunningForPlaygroundsKey = "XCODE_RUNNING_FOR_PLAYGROUNDS" static let xcodeRunningForPreviewsKey = "XCODE_RUNNING_FOR_PREVIEWS" - static let testPortKey = CodexReviewStoreTestEnvironment.portKey - static let testCodexCommandKey = CodexReviewStoreTestEnvironment.codexCommandKey - static let testDiagnosticsPathKey = CodexReviewStoreTestEnvironment.diagnosticsPathKey - static let reviewModeArgument = CodexReviewStoreTestEnvironment.reviewModeArgument - static let testPortArgument = CodexReviewStoreTestEnvironment.portArgument - static let testCodexCommandArgument = CodexReviewStoreTestEnvironment.codexCommandArgument - static let testDiagnosticsPathArgument = CodexReviewStoreTestEnvironment.diagnosticsPathArgument + static let testPortKey = "REVIEW_MONITOR_TEST_PORT" + static let testCodexCommandKey = "REVIEW_MONITOR_TEST_CODEX_COMMAND" + static let testDiagnosticsPathKey = "REVIEW_MONITOR_TEST_DIAGNOSTICS_PATH" + static let reviewModeArgument = "--review-monitor-review-mode" + static let testPortArgument = "--review-monitor-test-port" + static let testCodexCommandArgument = "--review-monitor-test-codex-command" + static let testDiagnosticsPathArgument = "--review-monitor-test-diagnostics-path" static func launchMode( environment: [String: String] = ProcessInfo.processInfo.environment, @@ -188,9 +188,6 @@ final class ReviewMonitorLifecycleController { func applicationShouldTerminate( replyingTo application: any ReviewMonitorTerminationReplying ) -> NSApplication.TerminateReply { - guard shouldManageEmbeddedServer else { - return .terminateNow - } guard terminationTask == nil else { return .terminateLater } @@ -203,11 +200,6 @@ final class ReviewMonitorLifecycleController { } } -@MainActor -private final class ReviewMonitorPresentationAnchorSource { - weak var window: NSWindow? -} - @MainActor struct ReviewMonitorAppDependencies { let store: CodexReviewStore @@ -225,23 +217,14 @@ struct ReviewMonitorAppDependencies { } } -private enum ReviewMonitorNativeAuthentication { - static let callbackScheme = "lynnpd.CodexReviewMonitor.auth" -} - @MainActor struct ReviewMonitorAppComposition { - typealias PresentationAnchorProvider = @MainActor () -> NSWindow? typealias LiveStoreFactory = ( CodexReviewRuntime.Preferences, - CodexReviewNativeAuthentication.Configuration?, CodexReviewAppServerLifecycleHandler? ) -> CodexReviewStore - var makeDependencies: ( - ReviewMonitorLaunchContext, - @escaping PresentationAnchorProvider - ) -> ReviewMonitorAppDependencies + var makeDependencies: (ReviewMonitorLaunchContext) -> ReviewMonitorAppDependencies var makeLifecycleController: ( any ReviewMonitorLifecycleStore, ReviewMonitorLaunchContext @@ -253,10 +236,7 @@ struct ReviewMonitorAppComposition { var makeSettingsWindowController: () -> NSWindowController init( - makeDependencies: @escaping ( - ReviewMonitorLaunchContext, - @escaping PresentationAnchorProvider - ) -> ReviewMonitorAppDependencies, + makeDependencies: @escaping (ReviewMonitorLaunchContext) -> ReviewMonitorAppDependencies, makeLifecycleController: @escaping ( any ReviewMonitorLifecycleStore, ReviewMonitorLaunchContext @@ -284,17 +264,16 @@ struct ReviewMonitorAppComposition { static func live( runtimePreferencesStore: any CodexReviewRuntime.PreferencesStore = CodexReviewRuntime.UserDefaultsPreferencesStore(), - makeLiveStore: @escaping LiveStoreFactory = { runtimePreferences, nativeAuthenticationConfiguration, appServerLifecycleHandler in + makeLiveStore: @escaping LiveStoreFactory = { runtimePreferences, appServerLifecycleHandler in CodexReviewStore.makeLiveStore( runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, appServerLifecycleHandler: appServerLifecycleHandler ) } ) -> ReviewMonitorAppComposition { let codexModelSource = ReviewMonitorCodexModelSource() return ReviewMonitorAppComposition( - makeDependencies: { context, presentationAnchorProvider in + makeDependencies: { context in if context.requestsPreviewContent { let previewContent = ReviewMonitorPreviewContent.makeContentSource() return ReviewMonitorAppDependencies( @@ -303,12 +282,7 @@ struct ReviewMonitorAppComposition { ) } let store = makeLiveStore( - runtimePreferencesStore.load(), - CodexReviewNativeAuthentication.Configuration( - callbackScheme: ReviewMonitorNativeAuthentication.callbackScheme, - browserSessionPolicy: .ephemeral, - presentationAnchorProvider: presentationAnchorProvider - ) + runtimePreferencesStore.load() ) { modelContainer in if let modelContainer { codexModelSource.install(container: modelContainer) @@ -326,7 +300,7 @@ struct ReviewMonitorAppComposition { ) } return ReviewMonitorWindowController( - appStore: dependencies.store, + store: dependencies.store, codexModelSource: codexModelSource, showSettings: showSettings ) @@ -345,16 +319,12 @@ struct ReviewMonitorAppComposition { final class ReviewMonitorAppDelegate: NSObject, NSApplicationDelegate { private let launchContextProvider: () -> ReviewMonitorLaunchContext private let composition: ReviewMonitorAppComposition - private let presentationAnchorSource = ReviewMonitorPresentationAnchorSource() - private lazy var launchContext = launchContextProvider() private var launchMode: ReviewMonitorLaunchMode { launchContext.launchMode } lazy var appDependencies: ReviewMonitorAppDependencies = { - composition.makeDependencies(launchContext) { [weak presentationAnchorSource] in - presentationAnchorSource?.window - } + composition.makeDependencies(launchContext) }() lazy var store: CodexReviewStore = appDependencies.store lazy var lifecycle = composition.makeLifecycleController(store, launchContext) @@ -362,7 +332,6 @@ final class ReviewMonitorAppDelegate: NSObject, NSApplicationDelegate { let windowController = composition.makeWindowController(appDependencies) { [weak self] in self?.showSettingsWindow(nil) } - presentationAnchorSource.window = windowController.window return windowController }() lazy var settingsWindowController = composition.makeSettingsWindowController() diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index a6aadbe3..5076bcc0 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -72,6 +72,31 @@ struct CodexReviewMonitorCITests { #expect(responder.replies == [true]) } + @Test func lifecycleStopsPreviewStoreWithoutStartingEmbeddedServer() async { + let store = FakeLifecycleStore() + let lifecycle = ReviewMonitorLifecycleController( + store: store, + shouldManageEmbeddedServer: false + ) + let responder = TerminationReplyRecorder() + + lifecycle.applicationDidFinishLaunching(launchMode: .application) + #expect(store.startArguments.isEmpty) + + let terminationReply = lifecycle.applicationShouldTerminate(replyingTo: responder) + #expect(terminationReply == .terminateLater) + + await store.stopStartedSignal.wait() + #expect(store.stopCallCount == 1) + #expect(responder.replies.isEmpty) + + await store.stopGate.open() + let reply = await responder.waitForReply() + + #expect(reply == true) + #expect(responder.replies == [true]) + } + @Test func appDelegateUsesInjectedCompositionForStartupDependencies() { let previousMainMenu = NSApp.mainMenu let previousServicesMenu = NSApp.servicesMenu @@ -82,13 +107,13 @@ struct CodexReviewMonitorCITests { NSApp.windowsMenu = previousWindowsMenu } - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedContext: ReviewMonitorLaunchContext? var capturedShowSettings: (@MainActor () -> Void)? let recorder = WindowControllerFactoryRecorder() let settingsWindowController = CountingWindowController() let composition = ReviewMonitorAppComposition( - makeDependencies: { context, _ in + makeDependencies: { context in capturedContext = context return ReviewMonitorAppDependencies(store: expectedStore) }, @@ -146,8 +171,10 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + makeDependencies: { _ in + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() @@ -193,8 +220,10 @@ struct CodexReviewMonitorCITests { @Test func appDelegateShowsInjectedSettingsWindowController() { let settingsWindowController = CountingWindowController() let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + makeDependencies: { _ in + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() @@ -225,10 +254,10 @@ struct CodexReviewMonitorCITests { var didCallLiveStoreFactory = false let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, - makeLiveStore: { _, _, _ in + makeLiveStore: { _, _ in didCallLiveStoreFactory = true Issue.record("Preview store creation should not build a live store.") - return CodexReviewStore.makePreviewStore() + return ReviewMonitorPreviewContent.makeContentSource().store } ) let context = ReviewMonitorLaunchContext( @@ -238,17 +267,10 @@ struct CodexReviewMonitorCITests { arguments: [], launchMode: .application ) - var didRequestPresentationAnchor = false - - let dependencies = composition.makeDependencies(context) { - didRequestPresentationAnchor = true - Issue.record("Preview store creation should not request a presentation anchor.") - return nil - } + let dependencies = composition.makeDependencies(context) #expect(dependencies.previewContent != nil) #expect(dependencies.previewContent?.store === dependencies.store) - #expect(didRequestPresentationAnchor == false) #expect(didCallLiveStoreFactory == false) #expect(context.shouldStartEmbeddedServer == false) } @@ -258,10 +280,10 @@ struct CodexReviewMonitorCITests { var didCallLiveStoreFactory = false let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, - makeLiveStore: { _, _, _ in + makeLiveStore: { _, _ in didCallLiveStoreFactory = true Issue.record("Preview window creation should not build a live store.") - return CodexReviewStore.makePreviewStore() + return ReviewMonitorPreviewContent.makeContentSource().store } ) let context = ReviewMonitorLaunchContext( @@ -271,10 +293,7 @@ struct CodexReviewMonitorCITests { arguments: [], launchMode: .application ) - let dependencies = composition.makeDependencies(context) { - Issue.record("Preview store creation should not request a presentation anchor.") - return nil - } + let dependencies = composition.makeDependencies(context) let windowController = composition.makeWindowController(dependencies) {} let rootViewController = try #require( @@ -325,14 +344,12 @@ struct CodexReviewMonitorCITests { let runtimePreferencesStore = RuntimePreferencesStoreStub( preferences: expectedRuntimePreferences ) - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedRuntimePreferences: CodexReviewRuntime.Preferences? - var capturedAuthenticationConfiguration: CodexReviewNativeAuthentication.Configuration? let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, - makeLiveStore: { runtimePreferences, authenticationConfiguration, _ in + makeLiveStore: { runtimePreferences, _ in capturedRuntimePreferences = runtimePreferences - capturedAuthenticationConfiguration = authenticationConfiguration return expectedStore } ) @@ -341,34 +358,21 @@ struct CodexReviewMonitorCITests { arguments: [], launchMode: .application ) - var didRequestPresentationAnchor = false - - let dependencies = composition.makeDependencies(context) { - didRequestPresentationAnchor = true - return nil - } + let dependencies = composition.makeDependencies(context) let store = dependencies.store #expect(store === expectedStore) #expect(dependencies.previewContent == nil) #expect(capturedRuntimePreferences == expectedRuntimePreferences) - #expect(didRequestPresentationAnchor == false) - #expect(capturedAuthenticationConfiguration?.callbackScheme == "lynnpd.CodexReviewMonitor.auth") - if case .ephemeral? = capturedAuthenticationConfiguration?.browserSessionPolicy { - } else { - Issue.record("Expected ReviewMonitor to use an ephemeral browser session.") - } - #expect(capturedAuthenticationConfiguration?.presentationAnchorProvider() == nil) - #expect(didRequestPresentationAnchor) } @Test func liveCompositionPassesAppServerLifecycleHandlerToLiveStoreFactory() { let runtimePreferencesStore = RuntimePreferencesStoreStub() - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedLifecycleHandler: CodexReviewAppServerLifecycleHandler? let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, - makeLiveStore: { _, _, appServerLifecycleHandler in + makeLiveStore: { _, appServerLifecycleHandler in capturedLifecycleHandler = appServerLifecycleHandler return expectedStore } @@ -379,7 +383,7 @@ struct CodexReviewMonitorCITests { launchMode: .application ) - let dependencies = composition.makeDependencies(context) { nil } + let dependencies = composition.makeDependencies(context) let store = dependencies.store #expect(store === expectedStore) @@ -618,8 +622,10 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + makeDependencies: { _ in + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() diff --git a/scripts/restore-source-mtimes.sh b/scripts/restore-source-mtimes.sh new file mode 100755 index 00000000..f922b72a --- /dev/null +++ b/scripts/restore-source-mtimes.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly TIMESTAMP_FLOOR=946684800 +readonly TIMESTAMP_SPAN=631152000 + +cd "$REPOSITORY_ROOT" + +# Restored build records compare input mtimes. A content-derived historical +# timestamp keeps unchanged inputs stable across runners while making a changed +# blob invalidate the incremental record. +while IFS= read -r -d '' tracked_file; do + blob="$(git rev-parse "HEAD:$tracked_file")" + if [[ -z "$blob" ]]; then + echo "No blob found for tracked build input: $tracked_file" >&2 + exit 1 + fi + + timestamp=$((TIMESTAMP_FLOOR + (16#${blob:0:8} % TIMESTAMP_SPAN))) + touch -t "$(date -r "$timestamp" +%Y%m%d%H%M.%S)" "$tracked_file" +done < <( + git ls-files -z -- \ + Package.swift \ + Package.resolved \ + Sources \ + Tests \ + Tools +) diff --git a/scripts/verify-ci-xcode.sh b/scripts/verify-ci-xcode.sh new file mode 100755 index 00000000..770e90f7 --- /dev/null +++ b/scripts/verify-ci-xcode.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${EXPECTED_XCODE_VERSION_LINE:?EXPECTED_XCODE_VERSION_LINE is required}" +: "${EXPECTED_XCODE_BUILD_LINE:?EXPECTED_XCODE_BUILD_LINE is required}" + +actual_version="$(xcodebuild -version)" +if ! grep -Fxq "$EXPECTED_XCODE_VERSION_LINE" <<< "$actual_version" \ + || ! grep -Fxq "$EXPECTED_XCODE_BUILD_LINE" <<< "$actual_version" +then + echo "Unexpected Xcode toolchain" >&2 + echo "expected:" >&2 + printf '%s\n%s\n' "$EXPECTED_XCODE_VERSION_LINE" "$EXPECTED_XCODE_BUILD_LINE" >&2 + echo "actual:" >&2 + printf '%s\n' "$actual_version" >&2 + exit 1 +fi + +printf '%s\n' "$actual_version" +swift --version + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + cache_id="$(printf '%s' "$actual_version" | tr '\n ' '--' | tr -cd '[:alnum:]._-')" + echo "cache-id=$cache_id" >> "$GITHUB_OUTPUT" +fi diff --git a/scripts/verify-text-transitions-api.sh b/scripts/verify-text-transitions-api.sh new file mode 100755 index 00000000..84d5a92c --- /dev/null +++ b/scripts/verify-text-transitions-api.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +set -euo pipefail + +readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly SDK_PATH="$(xcrun --sdk macosx --show-sdk-path)" +readonly TARGET="arm64-apple-macosx26.0" +readonly SCRATCH_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/text-transitions-api.XXXXXX")" +readonly DEBUG_API_SHA256="8b6f247ace04cfbdb38c131d935f9b8d6dcbfbb0409282ebb69d28a89aaa8a03" +readonly RELEASE_API_SHA256="51ef185f3a36321bc6038b578f0eb3e1b9260c4bfa7af4e7e9dfdc858082d16b" + +cleanup() { + rm -rf "$SCRATCH_DIRECTORY" +} +trap cleanup EXIT + +mkdir -p "$SCRATCH_DIRECTORY/debug" "$SCRATCH_DIRECTORY/release" + +sources=() +while IFS= read -r source; do + sources+=("$source") +done < <(find "$REPOSITORY_ROOT/Sources/TextTransitions" -name '*.swift' -type f -print | sort) +if [[ ${#sources[@]} -eq 0 ]]; then + echo "No TextTransitions sources found" >&2 + exit 1 +fi + +xcrun swiftc "${sources[@]}" \ + -parse-as-library \ + -emit-module \ + -enable-library-evolution \ + -emit-module-interface-path "$SCRATCH_DIRECTORY/debug/TextTransitions.swiftinterface" \ + -emit-module-path "$SCRATCH_DIRECTORY/debug/TextTransitions.swiftmodule" \ + -module-name TextTransitions \ + -swift-version 6 \ + -target "$TARGET" \ + -sdk "$SDK_PATH" \ + -Onone \ + -D SWIFT_PACKAGE \ + -D DEBUG \ + -enable-testing + +xcrun swiftc "${sources[@]}" \ + -parse-as-library \ + -emit-module \ + -enable-library-evolution \ + -emit-module-interface-path "$SCRATCH_DIRECTORY/release/TextTransitions.swiftinterface" \ + -emit-module-path "$SCRATCH_DIRECTORY/release/TextTransitions.swiftmodule" \ + -module-name TextTransitions \ + -swift-version 6 \ + -target "$TARGET" \ + -sdk "$SDK_PATH" \ + -O \ + -D SWIFT_PACKAGE + +normalize_api() { + local configuration="$1" + + sed '/^\/\/ swift-/d' \ + "$SCRATCH_DIRECTORY/$configuration/TextTransitions.swiftinterface" \ + > "$SCRATCH_DIRECTORY/$configuration.normalized.swiftinterface" +} + +verify_api() { + local configuration="$1" + local expected_sha256="$2" + local actual_sha256 + + actual_sha256="$(shasum -a 256 "$SCRATCH_DIRECTORY/$configuration.normalized.swiftinterface" | awk '{print $1}')" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "TextTransitions $configuration API changed" >&2 + echo "expected: $expected_sha256" >&2 + echo "actual: $actual_sha256" >&2 + echo "generated API: $SCRATCH_DIRECTORY/$configuration.normalized.swiftinterface" >&2 + trap - EXIT + exit 1 + fi + + echo "TextTransitions $configuration API verified: $actual_sha256" +} + +normalize_api debug +normalize_api release +verify_api debug "$DEBUG_API_SHA256" +verify_api release "$RELEASE_API_SHA256"