From 4e71a36ff453d521a0302d1a26ec009bb4b0d4cb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:48:44 +0900 Subject: [PATCH 01/62] docs(audit): add design audit report 2026-07-10 Multi-agent structural/API audit of CodexKit (3f6216c) and CodexReviewKit (6c1b432) against upstream codex (8347b8d) and openai-python (2.43.0): verified findings (50 CONFIRMED / 1 PLAUSIBLE / 2 REFUTED), owner maps, API coverage gaps, prioritized fix plan (P1-P3), and test checklist. Co-Authored-By: Claude Fable 5 --- Docs/design-audit-2026-07-10.md | 750 ++++++++++++++++++++++++++++++++ 1 file changed, 750 insertions(+) create mode 100644 Docs/design-audit-2026-07-10.md diff --git a/Docs/design-audit-2026-07-10.md b/Docs/design-audit-2026-07-10.md new file mode 100644 index 0000000..aa4f3fc --- /dev/null +++ b/Docs/design-audit-2026-07-10.md @@ -0,0 +1,750 @@ +# CodexKit / CodexReviewKit 設計・API 監査(2026-07-10) + +| 項目 | 内容 | +|---|---| +| 対象 | CodexKit `3f6216c`(CodexAppServerKit / CodexDataKit / CodexAppServerKitTesting)、CodexReviewKit `6c1b432` | +| 一次情報 | upstream codex `8347b8d`(/Users/kn/Dev/codex)、openai-python `2.43.0`(SDK ergonomics ベンチマーク) | +| 方法 | multi-agent 監査: 7 mapper(AppServerKit / DataKit / Testing / upstream protocol / openai-python / 消費側 workaround / CRK 構造)+ 3 cross 分析(coverage / conformance / DX)→ 重複統合(raw 130 → canonical 89)→ 領域別 adversarial 検証 | +| 検証ステータス | high+medium 53 件を検証: **CONFIRMED 50 / PLAUSIBLE 1 / REFUTED 2**。low 36 件は未検証(Appendix B) | + +読み方: 各 finding は Appendix A に id 付きで詳細(証拠 file:line、検証トレース)を収録。本文はクラスタ単位の構造分析。パス表記は `CK:` = CodexKit(`dependencies/CodexKit`)、`CRK:` = 本 repo、`UP:` = upstream codex。 + +## 1. 結論 + +PR #16(2026-07-02)時点の 3 弱境界のうち 2.5 は解消済み: + +1. **generation 境界 — 解消**。`withThreadEventGeneration` が turn/start・compact・review/start・resume の cursor 切替を所有し、`inferredCurrentGenerationEvents` は削除済み。owner レベルのテストで pin されている。 +2. **observation multicast — 解消**。`ThreadEventPump` が model mutation を所有し、`CodexAsyncStreamRelay` が per-subscriber broadcast を行う。 +3. **item identity — 形を変え残存**。`CodexMessageDelta.itemID` は依然 optional(upstream は required)で、fallback-ID/text-dedup 機構は実在しない `agent/message` decode が唯一の駆動源(→ `dk-optional-delta-id-workaround-layer`, `cov-dead-compat-notifications`)。 + +**現存する最大の欠陥は「upstream v2 終端契約の誤読」1 点に集約される。** この誤読が SDK 全層(router / turn sequences / DataKit phase)に染み出し、その代償として旗艦消費者である本 repo が SDK の高レベル review API(`progress` / `collect()` / `response`)を**一切使わず** raw events 上に終端分類を再実装している(`use-highlevel-surface-bypass`)。SDK の便利層を SDK 作者自身が使えないことが、この層の契約が誤っていることの最強の証拠である。 + +## 2. upstream 契約の一次事実(全て codex-rs 現物で確認済み) + +| protocol 事実 | 根拠(UP: codex-rs) | CodexKit の現状 | +|---|---|---| +| turn の terminal は **`turn/completed` のみ**。結果は payload の `turn.status`(completed / interrupted / failed / inProgress) | `app-server-protocol/src/protocol/v2/turn.rs:30-35, 392-395` | `turn/failed`・`turn/cancelled` を decode(`CK:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:661,748`)— upstream 全履歴に不存在 | +| `interrupted` はユーザー中断の**正常終端**(error なし) | turn_processor の interrupt 経路 | `CodexTurnStatus.isFailure` が interrupted/cancelled を失敗扱い(`CK:Sources/CodexAppServerKit/CodexDomainTypes.swift:1878`) | +| `thread/closed` は **idle unload 通知(再開可)**。終端ではない | `app-server/src/request_processors/thread_lifecycle.rs:406-436` | router が全 subscriber を終端、DataKit は走行中 turn を `.completed` に捏造(`CK:Sources/CodexDataKit/CodexModel.swift:1258`) | +| `item/updated`・`agent/message` という notification は存在しない | `protocol/common.rs:1613-1710`(grep 0 件) | 両方 decode。`agent/message` が fallback-ID subsystem の唯一の駆動源 | +| client→server の login request は `account/login/start` / `account/login/cancel` のみ | `protocol/common.rs:1001-1008` | `account/login/complete` + `nativeWebAuthentication` は **invented**(`git log --all -S` 空) | +| `thread/rollback` は "will be removed soon" | protocol 定義の deprecation 注記 | review restart / `rollback(turnCount:)` / `.revertTranscript` の 3 public 挙動が依存 | +| `account/rateLimits/updated` は sparse な rolling update(merge 前提) | notification 定義の doc | 置換適用(merge owner 不在)で secondary/planType が消える | +| server→client request(userInput / permissions)は typed 応答必須 | `ToolRequestUserInputResponse` 等の serde | デフォルトハンドラの `{}` 応答は upstream 側 serde 失敗 → error ログ付き deny 降格 | + +## 3. Owner map — SDK 側の弱境界(現状 → あるべき姿) + +| 責務 | 現状 owner | 問題 | あるべき owner | +|---|---|---|---| +| turn 終端の意味論 | `CodexTurnStatus.isFailure` + 各層の独自解釈 | interrupted=失敗の誤読が collect/progress/DataKit phase に増殖 | `CodexTurnStatus` 1 箇所での 3 値終端(成功 / 中断 / 失敗) | +| thread lifecycle | router(closed=終端と誤読) | unload を終端化、subscriber 即終了、DataKit が成功を捏造 | router が closed を「unload・再購読可」として扱う | +| エラー型 | **不在** — package `JSONRPC.Error` が素通り | 型で catch 不能。`CodexAppServerError` の 3 case は dead。`error.data`(CodexErrorInfo)全破棄 | send boundary 1 箇所での public typed error へのマップ | +| 資源解放 | **不在** — deinit ゼロ | router history 無限成長、close() 忘れで codex 子プロセス孤児化 | terminal turn 後の履歴 prune + deinit backstop | +| fetch mutation 戦略(DataKit) | **不在** — insert/archive/revalidate/remove/refresh が各自判断 | `fix(data)` 9 連続の根。insert だけ `usesServerOwnedOrdering` を見ない非対称が現存 | 単一の per-mutation strategy 型 | +| wire fixture | **不在** — SDK tests / Testing target / CRK preview の 3 箇所で手書き | 実在しない `turn/failed`・status `"cancelled"` が fixture に混入済み | Testing target が typed emitter を提供 | + +### CodexReviewKit 側 owner map(要点) + +健全な境界(維持すべきもの): + +- domain core(`CodexReviewKit` target)は transport 非依存(import scan で確認: Foundation/Observation/ObservationBridge/Network のみ) +- UI target は CodexAppServerKit を import しない。preview は `CodexAppServerTestRuntime`(transport 層)で fake し、本番 data flow を保存 +- review event の generation/identity は `ReviewWorkerEventSource`(単調増加 subscription ID + attemptID gating)で構造的に所有 — PR #16 型の cursor dance をこの repo では解消済み +- MCP session lifecycle は `closeSession` 1 経路に集約。chat-log render は純粋な再投影 + document diff + +弱い境界: + +- **login/auth teardown が owner 不在**: `LiveCodexReviewStoreBackend` の 9 mutable fields を 7 つの exit path が手動 reset(`arch-login-state-no-owner`、repo 内で最弱) +- attemptID identity が 3 層で `"attempt-1"` に fallback し、lookup 経路の get-or-create が registry を clobber(`arch-attempt-id-fallback`) +- source/review の dual-thread identity を 3 層が独立に再導出(`use-dual-thread-identity`) +- `CodexReviewHost` + `DirectCodexReviewStoreBackend` はテスト専用の重複 production code(copy-drift 実在: `arch-host-target-test-only`) + +## 4. DX 評価(openai-python の 17 契約をベンチマーク) + +**CodexAppServerKit**: 層設計は優秀(transport actor → per-thread serial lane + overload retry → replay 付き router → 値型 domain surface)。寛容 decode(`.unknown` / `rawPayload` 保持)、-32001 限定の jittered retry、fail-fast init、再生可能 stream、tri-state config patch、transport 層 testing product はベンチマーク水準を満たす。弱いのは異常系一式: + +- エラー: 消費者が型で catch できない(CRK に typed catch 0 件、`localizedDescription` 還元 ~30 箇所) +- タイムアウト: init handshake・全 request・`collect()` に deadline なし +- キャンセル: task cancel が `transportClosed` に化ける。同じ `CodexResponseStream` で iteration break(server 継続)と collect() 中 cancel(server 中断)の意味論が割れており、README 以外に文書なし +- `turn/interrupt` の正しさがエラーメッセージ文字列 parse(`" but found "` split)に依存 + +**CodexDataKit**: SwiftData アナロジーは object identity については誠実(fetch は identity 安定な live `@Observable` model を in-place mutate)だが、4 点で開発者を裏切る: + +1. **ライブではない** — `@CodexQuery` は外部変更(CLI での thread 作成・rename・archive)を観測しない。upstream の `thread/*` notification の ingestion が無い +2. `#Predicate` は 5-key whitelist で、外すと **SwiftUI update() 内で `preconditionFailure`**(SwiftData は throw) +3. `isArchived` に触れない predicate に `archived == false` が**暗黙注入**される(文書化なし) +4. `includePendingChanges` の意味が SwiftData と別物で、公開 mutable フィールドがあるのに `save()` が無い + +2 パッケージ分割自体は明快で、下方 interop(`container.appServer` / `CodexStartedReview.session`)も実際に使われている。ただし AppServerKit 側での mutation が DataKit fetch results に反映されない coherence 境界は未文書。 + +## 5. API 過不足 + +### 過剰(存在しない・無意味な API) + +| API | 問題 | finding | +|---|---|---| +| `account/login/complete` + `nativeWebAuthentication` | upstream 全履歴に不存在。素の codex では native web-auth が無言で never engage。fork 前提なら明示が必要 | `cov-invented-login-complete` | +| `turn/failed`・`turn/cancelled`・`item/updated`・`agent/message` decode | v2 に不存在。public `.turnFailed` は永久に発火しない。`agent/message` が fallback-ID 機構を駆動。CRK preview が `item/updated` に依存し、invented semantics が外に漏れている | `cov-dead-compat-notifications` | +| `CodexTurnStatus.cancelled` | protocol が emit しない独自 case。CRK が分岐している | `cov-invented-turn-status`(low) | +| `CodexReviewSession.steer()` | upstream が review turn への steer をカテゴリカルに拒否(`activeTurnNotSteerable`)— 必ず失敗する public API | `conf-review-steer-always-rejected`(low) | +| `Permissions.profileSelection`(object 形) | upstream は plain string 期待。latent wire break | `conf-permissions-object-shape`(low) | + +### 不足(消費側が代償を払っている) + +| 欠落 | 消費側の代償 | finding | +|---|---|---| +| typed error taxonomy + `error.data` 保持 | typed catch 0 件、message 文字列 match、SDK 自身も interrupt 判定を文字列 parse | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded` | +| timeout | ホスト側で手作りタイムアウト。wedged binary で起動永久ハング | `dx-no-timeout-story` | +| outbound raw JSON-RPC escape hatch | 週次で動く upstream の新 method を呼べない(send path は 1 本なので追加は安価) | `dx-no-raw-escape-hatch` | +| server→client request の typed surface + `serverRequest/resolved` | `{}` 応答は upstream 側 serde 失敗 → deny 降格。custom handler は abort 時に永久待ち | `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | +| identity での cancel | registry miss 時にセッション全体を resume してから cancel | `use-resume-to-cancel` | +| review 最終出力の所在の契約 | 3 段 fallback チェーン + 2 層での失敗合成(この領域だけで fix 5+ commits) | `use-review-output-location` | +| 死活監視 surface | `accountEvents` stream の throw を「サーバー死亡」のサイドチャネルとして利用 | `use-runtime-death-side-channel` | +| `deprecationNotice` / `error.willRetry` の typed 化 | thread/rollback 除去シグナルと transient-retry シグナルが不可視 | `cov-error-warning-untyped` | +| thread lifecycle notification の DataKit ingestion | MCP provider が read 毎に全 refresh + silent catch | `dk-query-not-live-external`, `use-mcp-refresh-fallback` | + +### 適切に無いもの(問題なし) + +`fs/*`、`mcpServer/*`、skills、plugins、exec/process、realtime、remoteControl 系 — 消費者に需要がなく workaround 圧力も観測されず。CRK production code に手書き JSON-RPC はゼロ(raw 文字列は preview/test transport に限定)。`review/start`・ReviewTarget・ReviewDelivery は upstream と正確に一致。v1 deprecated methods は正しく不採用。 + +## 6. 修正方針(優先順位付き) + +### P1 — SDK の契約修正 + +| # | 修正 | 回復する invariant | findings | +|---|---|---|---| +| 1 | turn 終端の 3 値化: `isFailure` から interrupted/cancelled を外し、`collect()`/`respond()` は中断時に throw せず返す。progress の `.failed` 報告も分岐 | ユーザー中断は失敗ではない(upstream 契約) | `conf-interrupted-is-failure`, `dx-cancel-semantics-inconsistent` | +| 2 | `thread/closed` = unload: subscriber を終端しない、DataKit が `.completed` を捏造しない | thread の生死は server が所有し unload は状態遷移 | `ask-thread-closed-terminal`, `dk-closed-fabricates-completion` | +| 3 | send boundary 1 箇所で typed error 化(code + CodexErrorInfo + requestID)、CancellationError 透過、per-request timeout。interrupt 判定を `codexErrorInfo` の typed 判別へ | 失敗の分類は SDK が所有する | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded`, `ask-cancel-collect-misreported`, `ask-interrupt-error-string-parsing`, `dx-no-timeout-story` | +| 4 | terminal turn 後の router history prune(replay 契約と両立する設計)+ deinit backstop | 接続寿命 = 資源上限 | `ask-router-history-unbounded`, `ask-process-leak-no-deinit` | +| 5 | invented login surface の決着(fork 前提の明示 or 撤去)。`thread/rollback` 依存の脱却計画(まず deprecationNotice の typed 化) | public API は実在する upstream 契約に裏付けられる | `cov-invented-login-complete`, `cov-rollback-deprecated` | + +P1-1/2 が入ると、CRK 側 terminal-cascade 3 層(`use-terminal-cascade`)、`.closed→.failed` 変換(`arch-closed-maps-to-failed`)、high-level surface bypass(`use-highlevel-surface-bypass`)の削除条件が立つ。 + +### P2 — 再発防止の契約整合 + +| # | 修正 | findings | +|---|---|---| +| 6 | DataKit mutation 戦略の単一 owner 化 + `load()` の直列化 / generation token | `dk-mutation-strategy-scattering`, `dk-unserialized-fetch-loads` | +| 7 | dead decode 4 種の削除 + `CodexMessageDelta.itemID` required 化(fallback-ID subsystem ごと削除) | `cov-dead-compat-notifications`, `dk-optional-delta-id-workaround-layer` | +| 8 | Testing fake の契約一致: unstubbed fail-fast、thread/list の archived/sort 実装、server-request 注入 API、typed wire emitter | `test-unstubbed-method-silent-success`, `test-store-ignores-archived-and-sort`, `test-no-server-request-injection`, `test-handrolled-notification-schemas-drift` | +| 9 | predicate の throwing validation 化 + archived 暗黙注入の廃止(最低限、両方の文書化) | `dk-predicate-runtime-crash-contract`, `dk-implicit-archived-scope` | +| 10 | server request の typed surface + `serverRequest/resolved` 処理 | `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | + +### P3 — 消費側の整理 + +| # | 修正 | 条件 | +|---|---|---| +| 11 | terminal-cascade / `.closed→.failed` / resume-to-cancel / observe 直列化の縮小 | P1-1,2 と SDK cancel API の後 | +| 12 | **今すぐ削除可能**: item-status 再導出 cascade(SDK `2a4d2f5` が所有済み — 検証で確定)、dead adapter branches、`CodexReviewHost` + `DirectCodexReviewStoreBackend` | 即時 | +| 13 | login teardown の owner 化(9 fields を単一 session 型へ)、`"attempt-1"` fallback 除去、ForTesting forwarder 3 層(~100 accessors)の直接 seam 化 | 即時 | + +## 7. won't-fix(観測誤りとして反証済み) + +- **`test-fake-close-clean-finish-hangs-subscribers`**(fake close() で subscriber がハング): fake の `close()` は package-scoped で消費者から到達不能。public 経路 `CodexAppServer.close()` は `router.stop()` が先に throwing finish するためハングは起きない。`finishNotificationStreams(throwing:)` は public(`CK:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1000`)。残る clean/throwing 終端の非対称は low。 +- **`use-item-status-rederivation`**(SDK が item status を finalize しない): 前提が誤り。CodexKit `2a4d2f5`(2026-07-01)が全 terminal 経路で `itemByApplyingTerminalLifecycleStatus` を適用済み(`CK:Sources/CodexDataKit/CodexModel.swift:1703-1740`)、exitCode ルールも SDK 側に同一実装(:1765-1773)。CRK 側 projection cascade(`CRK:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:557-596`)は live workaround ではなく削除可能な残骸 → P3-12。 + +## 8. テストチェックリスト + +- [ ] `turn/completed(status=interrupted)` で `collect()` が throw しない — sibling: `respond()`、`progress`、DataKit `chat.phase` +- [ ] `thread/closed` 後に同 thread を resume でき、subscriber が終端しない +- [ ] 実 wire 形状(`turn/completed` + status=failed + `turn.error`)で `chat.phase == .failed` を pin(fictional `turn/failed` テスト `CK:Tests/CodexDataKitTests/CodexDataKitTests.swift:8091` の置換) +- [ ] fake: unstubbed method で fail-fast +- [ ] fake: `thread/list` が archived・sortKey・sortDirection を尊重 +- [ ] cancel 中の `respond()` が `CancellationError` を投げる +- [ ] 並行 `load()` の順序(A→B→A 完了で items/cursor が一致) +- [ ] `close()` なしの drop で子プロセスが reap される +- [ ] 長寿命接続で router history が prune される +- [ ] `serverRequest/resolved` で custom handler の待機が解放される + +**観測性の欠落**: `error` notification の `willRetry`(唯一の transient-retry シグナル)と `deprecationNotice` が `.unknown` 経由で不可視。typed event 化を P2-10 に含める。 + +## 9. 監査の限界 + +- low 36 件(Appendix B)は adversarial 検証を通していない(レートリミット対応で検証対象を high+medium に絞った)。 +- ReviewChatLogUI / ReviewUI の view 層は core state ファイル精読 + view skim に留まる。 +- `TextTransitions`、`scripts/`、`Tools/` は対象外。 + +--- + +## Appendix A: 検証済み findings(high + medium、51 件) + +各項目: 検証 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 mutable fields reset by hand at 7 call sites in LiveCodexReviewStoreBackend + +**CONFIRMED / high / architecture** — `CodexReviewKit:Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift:218` + +Login-flow state (loginChallenge, loginBackend, loginAppServer, loginCodexHomeURL, loginActivation, isWaitingForLoginAccountUpdate, activeAuthenticationSession, authenticationTask, loginNotificationTask) is torn down by manually nil-ing/cancelling each field in 7 places with subtly different orderings and subsets (cancelAuthentication, startLogin catch, monitorAuthenticationSession catch, handleAuthenticationSessionCancelled, handleLoginCompletedNotification failure, finishCompletedLoginAfterAccountUpdate, takeLoginRuntimeForCleanup). Any new exit path must replicate the full reset or leak an isolated app-server process/temp CODEX_HOME. The strongest owner-absent signal in the repo; the 2127-line class also owns app-server lifecycle, MCP HTTP server, rate-limit refresh, and account-registry persistence. + +- 証拠: LiveCodexReviewStoreBackend.swift:218-227 fields; reset copies :670-702, :915-940, :974-991, :1033-1062, :1300-1321, :1338-1384, :1559-1578 — same invariant, different order/subset. +- 検証時の訂正: Field count is 10, not 9 (finding omitted authNotificationTask :226, which is app-server-scoped rather than login-scoped, so 9 login-flow fields is defensible). Reset-site count: 7 clusters as claimed, plus an 8th partial subset reset inside startLogin at :885-888 the finding missed — strengthening, not weakening, the claim. +- 修正の方向: 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` — Cancelling a task awaiting respond()/collect() surfaces transportClosed instead of CancellationError + +**CONFIRMED / high / bug** — `CodexKit:Sources/CodexAppServerKit/CodexTurnSequences.swift:490` + +On task cancellation AsyncThrowingStream returns nil (does not throw), so collect()'s for-loop exits without a terminal event and throws CodexAppServerError.transportClosed — misclassifying every user cancellation as a transport failure for respond(to:), CodexResponseStream.collect(), and waitForCancelledResponse (CodexDomainTypes.swift:2111). Consumer already works around it: AppServerCodexReviewBackend checks Task.isCancelled explicitly (:753) and its `catch is CancellationError` branch (:759) is unreachable. + +- 証拠: 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() (CodexDomainTypes.swift:1994-2008) awaits turn.result() → CodexResponseCollector.collect iterating turn.events (CodexTurnSequences.swift:468). 3) Consumer cancels the awaiting Task; onCancel fires the detached `Task { try? await turn.interrupt() }` (:2002-2007); the AsyncThrowingStream iterator returns nil per cancellation semantics. 4) The for-loop exits without .completed/.failed → `throw CodexAppServerError.transportClosed` (CodexTurnSequences.swift:490). 5) collect()'s catch runs handleFailure() then rethrows (:1999-2000) — caller receives .transportClosed for a user-initiated cancel; same shape for waitForCancelledResponse (CodexDomainTypes.swift:2111). +- 修正の方向: collect()/result() own the terminal contract: after loop exit without terminal, check Task.isCancelled and throw CancellationError (or typed .cancelled); reserve transportClosed for real transport termination. + +### `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); neither the transport nor CodexAppServer has a deinit backstop, and the child runs in its own process group (POSIX_SPAWN_SETPGROUP), so losing the last reference orphans a running `codex app-server` process. Repeated container recreation (login change, tests forgetting close()) accumulates orphans; reaping (reapIfExited) only runs inside terminateAndWait. + +- 証拠: AppServerProcessTransport.swift:153-172 close()/closeTransport only termination paths; :662-663 setpgroup; grep deinit over Sources/CodexAppServerKit = zero hits; CodexAppServer.swift:216-223 close() is sole teardown. +- 検証時の訂正: Finding understates it: not only is there no deinit backstop, a router-side retain cycle (routerTask strongly captures the router at CodexAppServerNotificationRouter.swift:89-97 while the router stores it at :19) means the transport can never deallocate without close(), so the Swift objects leak too and any dealloc-based stdin-EOF fallback is unreachable. +- 失敗トレース: 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. +- 修正の方向: Transport owns 'no reachable transport ⇒ no child process': add a deinit that signals the process group (synchronous kill + detached reap) or hold the pid in a terminator token independent of actor lifetime; at minimum log the leaked pid. + +### `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: prune turn history at terminal+no-replay-consumers, and drop thread history behind the current generation cursor (beginThreadEventGeneration already knows the cutoff). + +### `conf-interrupted-is-failure` — User-interrupted turns are classified as failures across every SDK layer (isFailure includes interrupted/cancelled), so collect()/progress/DataKit all misreport user cancels as errors + +**CONFIRMED / high / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:1878` + +Upstream, the only turn-terminal event is turn/completed and status=interrupted is a normal non-failure outcome (interrupt → status Interrupted, error None). CodexTurnStatus.isFailure returns true for .interrupted and .cancelled and every terminal path keys off it: CodexResponseCollector/respond() throw turnFailedWithResponse, turn/review progress sequences yield phase .failed, CodexModel.fail(with:"interrupted") marks the whole chat .failed on turnCompleted and again on refresh (CodexModel.swift:1145-1151, 2371-2376), with CodexDataPhase having no non-failure interrupted terminal. waitForCancelledResponse uses a third inconsistent mapping. Consumer proof: zero collect() call sites in CodexReviewKit; the backend re-splits interrupted/cancelled back out of the failure path (AppServerCodexReviewBackend.swift:623-633) and elsewhere guesses via `error is CancellationError`, missing server-side interrupts. Cross-ref use-terminal-cascade, use-highlevel-surface-bypass, cov-invented-turn-status. + +- 証拠: CodexDomainTypes.swift:1878-1885 isFailure incl. interrupted/cancelled; CodexTurnSequences.swift:303-309,440-447,477-487; CodexModel.swift:1145-1151,2371-2376; CodexDomainTypes.swift:2088-2112 divergent waitForCancelledResponse; upstream turn.rs:28-36 TurnStatus, bespoke_event_handling.rs:1438-1460 interrupt → Interrupted/error None; consumer AppServerCodexReviewBackend.swift:626-631. +- 検証時の訂正: Citation nit: TurnStatus is turn.rs:29-35 (cited 28-36); DataKit refresh re-fail is CodexModel.swift:2368-2372 (cited 2371-2376). Substance accurate. +- 失敗トレース: (1) User cancels a running turn (CodexResponseStream.cancel() -> turn/interrupt, or any server-side interrupt); (2) upstream emits turn/completed {turn.status: "interrupted", error: null} (bespoke_event_handling.rs:1448-1459); (3) router decodes .completed(CodexResponse status .interrupted) (CodexAppServerNotificationRouter.swift:659-660, :1011-1026; "interrupted" -> .interrupted CodexDomainTypes.swift:1852-1853); (4) CodexResponseCollector.collect / CodexTurn.result: result.status?.isFailure == true because isFailure includes .interrupted (CodexDomainTypes.swift:1878-1885) -> throws CodexAppServerError.turnFailedWithResponse (CodexTurnSequences.swift:482-484) — a normal user cancel surfaces as a thrown turn failure; (5) progress sequences yield phase .failed(.turnFailedWithResponse) (CodexTurnSequences.swift:303-309, :440-447); (6) DataKit marks the whole chat .failed("interrupted") on turnCompleted (CodexModel.swift:1145-1151) and again on every refresh (:2368-2372), with no non-failure interrupted phase available (CodexDataPhase.swift:3-8); (7) consumer must compensate by re-splitting interrupted/cancelled out of the failure path (AppServerCodexReviewBackend.swift terminalFailureEvents ~:626-633). +- 修正の方向: The terminal-outcome type owns the distinction: replace isFailure-driven branching with a three-way outcome (completed | interrupted | failed(TurnError)) on CodexResponse/progress phases; collect() returns the response for any terminal status, throwing only for failed/errorMessage; DataKit lands interruption as loaded/idle with turn.status == .interrupted. + +### `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. Against a stock server: the extra field is silently ignored, the response never echoes nativeWebAuthentication, so the consumer's gate `completesLoginThroughCallback: nativeCallbackScheme != nil` is always false and the ASWebAuthenticationSession callback-completion feature silently never engages; completeLogin would fail method-not-found. Consumer ships real wiring and CodexKit's README documents it as standard API with no fork requirement noted. If a patched codex build is intentionally targeted, that dependency is undocumented and unpinned. + +- 証拠: AppServerRequests.swift:2285 method, :2144-2162 NativeWebAuthentication + Params field; CodexAppServer.swift:799-812,855-862; upstream common.rs:1001-1013 no login/complete, account.rs:68-86,124-137 no nativeWebAuthentication; git log --all -S in /Users/kn/Dev/codex returns nothing; consumer LiveCodexReviewStoreBackend.swift:911,961; README.md:418-421. +- 検証時の訂正: Citation fix: the README documenting the flow is CodexKit Sources/CodexAppServerKit/README.md:414-421, not the top-level README.md:418-421 (top-level README has no login content, 94 lines). Method line is AppServerRequests.swift:2285 as cited. +- 失敗トレース: 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, no deny_unknown_fields; (3) response is {type:"chatgpt", loginId, authUrl} only (account.rs:~125-137), so decodeIfPresent(nativeWebAuthentication) yields nil (AppServerRequests.swift:2207-2210) and CodexChatGPTLogin.nativeWebAuthentication == nil; (4) consumer gate completesLoginThroughCallback: nativeCallbackScheme != nil is always false (LiveCodexReviewStoreBackend.swift:911), so the ASWebAuthenticationSession callback-completion feature never engages, silently; (5) any caller that follows the README's documented step 3 and calls completeLogin sends method account/login/complete (AppServerRequests.swift:2285) which no codex revision has ever implemented -> JSON-RPC method-not-found at runtime. +- 修正の方向: 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 fails at runtime with method-not-found mid-recovery, after the turn was already interrupted. Compounding: deprecationNotice notifications are surfaced only as .unknown (cross-ref cov-error-warning-untyped), so the removal warning is invisible. + +- 証拠: 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. +- 修正の方向: Decide what restart/revert mean without rollback (upstream direction is fork/resume-based history editing), isolate the dependency behind one internal seam, and surface deprecationNotice as a typed event so consumers get the removal signal. + +### `dx-error-taxonomy-uncatchable` — Public API throws package/private error types; CodexAppServerError taxonomy is mostly dead — consumers cannot catch anything by type + +**CONFIRMED / high / api-design** — `CodexKit:Sources/CodexAppServerKit/JSONRPC.swift:33` + +Every request rethrows package-scoped JSONRPC.Error unmapped (uncatchable by type); the most common first-run failure (codex not installed) throws private AppServerProcessTransportError; undecodable responses throw raw DecodingError. CodexAppServerError's serverBusy/retryLimitExceeded/malformedNotification have zero throw sites, .jsonRPC is thrown only for client-side login-URL validation with a fabricated -32602, and after overload retries the raw JSONRPC.Error is rethrown instead of .retryLimitExceeded. router.stop() also finishes all event streams with JSONRPC.Error.closed even on graceful close(), so long-lived for-await loops throw an untypeable error on normal shutdown. Consumer confirms: zero typed catches in CodexReviewKit, 30+ localizedDescription reductions — binary-missing, server-rejection and SDK decode bugs render identically. + +- 証拠: JSONRPC.swift:3,33-48 package enum; AppServerClient.swift:115-134 rethrows unmapped; AppServerProcessTransport.swift:873 private error enum; CodexDomainTypes.swift:2839-2841 dead cases (grep: declaration-only); CodexAppServer.swift:1105-1133 only .jsonRPC sites; router stop CodexAppServerNotificationRouter.swift:168-172; consumer LiveCodexReviewStoreBackend.swift:511 et al. +- 検証時の訂正: Consumer localizedDescription count is 37 (finding said 30+ — consistent). +- 修正の方向: AppServerClient owns the transport→domain error boundary: map JSONRPC/transport/decode failures into public CodexAppServerError cases (spawnFailed, serverError(code:message:), invalidResponse, transportClosed) at the single send boundary, delete or wire the dead cases, and finish streams without error on graceful close. + +### `dx-no-timeout-story` — No deadline/timeout anywhere: init handshake, every request, and collect() can hang forever with no dedicated timeout error + +**CONFIRMED / high / api-design** — `CodexKit:Sources/CodexAppServerKit/AppServerClient.swift:89` + +No timeout mechanism exists in CodexAppServerKit (grep-verified; the only Duration uses are shutdown grace and turn metadata). CodexAppServer.init awaits the initialize handshake with no deadline (wedged binary hangs app startup forever); every request parks a CheckedContinuation resolved only by response or transport close; collect()/turn streams finish only on a terminal turn event or close — the router finishes thread subscribers on thread/closed but not turn subscribers, so one missing turn/completed wedges collect() permanently. No TimeoutError type exists. The consumer compensates with hand-rolled timeout scaffolding (observation awaiter, worker drain, test wrappers). + +- 証拠: AppServerClient.swift:89-137 no deadline; AppServerProcessTransport.swift:113-131 parked continuation; router :308-310 vs :333-335 asymmetry; CodexDomainTypes.swift:404-441 no deadline field; consumer ReviewObservationAwaiter.swift:40-56, CodexReviewStoreCancellation.swift:243-254, CodexReviewStoreReviews.swift:34-59. +- 修正の方向: AppServerClient owns per-request deadlines (optional Duration raced via structured concurrency, dedicated public timeout error distinct from transportClosed); Configuration owns the handshake deadline; streaming turns get an inter-event-gap deadline. + +### `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 with every thread stamped with the query's archived flag (fabricated scoping), and list order is store insertion order rather than the recency/created_at descending order DataKit explicitly trusts. Consumer previews already hand-stub thread/archive and track archived IDs themselves — the workaround signal that the store's mutation/scope owner is missing. + +- 証拠: CodexAppServerTestRuntime.swift:706-754 (no archived/sortKey/sortDirection reference); Params carry them AppServerRequests.swift Thread.List.Params; DataKit pins them CodexAppServerKitTests.swift:592-618; archived stamped CodexAppServer.swift:657; upstream thread.rs:1077-1094; consumer workaround ReviewMonitorPreviewAppServerRuntime.swift:335-341. +- 検証時の訂正: The citation 'archived stamped CodexAppServer.swift:657' is imprecise: :657 only serializes query.archived into the request; the stamping owner is DataKit's CodexFetchRequest.swift:1134-1139, which sets isArchived from the query scope on every returned chat. List order is init-order with upsert-moved-to-front (recency-of-mutation), not strict insertion order, but it still ignores sortKey/sortDirection and the upstream created_at-descending default. Upstream cite is protocol/v2/thread.rs. +- 失敗トレース: 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 dedups review output and reasoning mirrors by normalized-text equality and re-parses rawPayload JSON to absorb unstable item identity + +**CONFIRMED / high / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:624` + +One logical entry reaches the consumer as multiple items with different IDs (review output as exitedReviewMode item AND assistant message; one reasoning entry under two payload kinds: agent_reasoning event mirror + reasoning item). The projection maintains three text-equality compensation mechanisms — ReviewOutputKey turn-scoped dedup, the PendingReasoningMirrors pairing state machine keyed by scopeID+trimmed text, and RawPayloadKind JSON-decoding of item.rawPayload because the typed API exposes no payload-kind discriminator. Recurrence: 7 of 11 commits touching the file fix this dedup/status area (45b64f1, 537a9e4, 4c79ed5...). The SDK independently does its own text-signature fallback resolution (CodexFallbackAgentMessageSignature) — the same identity invariant implemented twice at two layers with no owner (cross-ref dk-optional-delta-id-workaround-layer). + +- 証拠: 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. +- 修正の方向: CodexDataKit owns canonical item identity: one persisted item per logical entry with stable itemID and a typed payloadKind discriminator; consumer text-matching and raw-JSON parsing then die. + +### `use-terminal-cascade` — Three-layer terminal-status cascade in the consumer compensates for the SDK's missing cancelled-terminal contract + +**CONFIRMED / high / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:812` + +Because CodexKit has no first-class cancelled terminal (interrupted classified as failure; cross-ref conf-interrupted-is-failure), the consumer rewrites terminal status at three layers: backend adapter re-derives .cancelled from interrupted/cancelled status; ReviewBackendEventSession emits a synthetic .cancelled on finish and ignores all later events via a finished flag (metrics.ignored instruments expected duplicate/late terminals); the store's completePendingCancellationIfNeeded is called at 7 sites and rewrites ANY backend terminal to cancelled while cancellationRequested is set. Expected: one typed terminal per turn from the SDK. + +- 証拠: CodexReviewStoreReviews.swift:812 (also 685,702,706,729,735,843); ReviewBackendEventSession.swift:124-127 finished guard, :144-146 synthetic .cancelled; AppServerCodexReviewBackend.swift:627-633; SDK CodexDomainTypes.swift:1878-1882, router :661,:748. +- 検証時の訂正: completePendingCancellationIfNeeded has 6 call sites (CodexReviewStoreReviews.swift:685,702,706,729,735,812), not 7; 7 counts the definition at :843. Everything else accurate. +- 修正の方向: CodexAppServerKit owns terminal typing: review surfaces deliver exactly one typed terminal (completed|cancelled|failed) per turn, deriving cancelled from turn/completed+interrupted; consumer layers collapse to record-keeping. + +### `arch-attempt-id-fallback` — attemptID identity is fabricated by "attempt-1" fallbacks and unknown attempts silently get a fresh registered event session that clobbers thread registries + +**CONFIRMED / medium / bug** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:349` + +Attempt identity gates event consumption and session lookup, yet is defaulted in three layers (Run.init default, decode fallback, backendRun reconstruction all "attempt-1"). When cancelReview takes the backendRun fallback branch and the backend has no session for that attemptID, reviewEventSession(for:) silently creates AND registers a new session, overwriting activeReviewAttemptIDByThreadID and the canonical-threadID map for all associated threads — a get-or-create mutation on what callers treat as a lookup. Same fallback-identity pattern the prior CodexKit audit flagged, one layer up. + +- 証拠: AppServerCodexReviewBackend.swift:349-358 get-or-create+register, :360-382 registry overwrite; CodexReviewTypes.swift:239,254 fallbacks; CodexReviewStoreReviews.swift:906 reconstruction fallback, :431-450 caller branch, :1027-1029 attemptID gate. +- 検証時の訂正: The registry-clobber harm is narrower than the wording implies: reaching the fallback branch with a live competing attempt on the same threadID requires a persisted record whose attemptID is nil while another attempt reuses that threadID (thread reuse exists — restartPreparedReview keeps interruptedRun.threadID :247 — but restarts persist real attemptIDs via applyBackendRun :237, so the collision needs legacy data). The unconditional facts stand: a cancel of a stale record fabricates identity and mutates registries via what every caller treats as a lookup. +- 失敗トレース: 1) Run record persisted with threadID set but attemptID nil (legacy schema; ReviewRunCore.swift:5 Optional, CodexReviewTypes.swift:254 decode fallback). 2) After relaunch, runtimeState has no activeRun, so cancelReview takes the backendRun branch (CodexReviewStoreReviews.swift:431) with fabricated attemptID "attempt-1" (:906). 3) backend.interruptReview → AppServerCodexReviewBackend.swift:187 reviewEventSession(for:) → no session for "attempt-1" → new AppServerReviewEventSession created and registered (:355-356). 4) registerReviewEventSession overwrites activeReviewAttemptIDByThreadID[threadID]="attempt-1" and canonical map for all associated threads (:373-380); any later thread-keyed lookup (finishReviewEventStream :447, metrics :324) resolves to the fabricated session instead of a real one. 5) The new session has no reviewSession, so cancelReviewTurn's session path returns nil (:736-738) and the code silently resume-and-cancels via appServer (:466-470) — a phantom session remains registered for a nonexistent attempt. +- 修正の方向: Backend owns attempt identity: interrupt/cleanup for an unknown attemptID is a no-op-with-log or explicit error, never a fabricated registered session; drop the "attempt-1" fallbacks and make attemptID non-optional at the store record so missing identity fails fast. + +### `arch-closed-maps-to-failed` — Consumer maps CodexReviewEvent.closed (upstream idle-unload, resumable) to review failure + +**CONFIRMED / medium / protocol-mismatch** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:584` + +AppServerTypedReviewEventAdapter maps .closed → [.failed("Review thread closed.")] and statusChanged(.notLoaded) → failed, turning an unload notification into a terminal user-visible failure. The SDK forwards .closed with no semantics and its progress sequence silently ends on it (cross-ref ask-thread-closed-terminal), so the consumer invents semantics. Harmless today only because the finished-flag terminal-cascade guard ignores post-terminal events; if .closed ever precedes the terminal (e.g. deferred deleteThread ordering changes), a successful review is recorded failed. The backend already has restart/resume machinery (prepareReviewRestart/resumeReview) that .closed bypasses. + +- 証拠: AppServerCodexReviewBackend.swift:583-584, :574-575; restart machinery :201-257,466-470; upstream thread_lifecycle.rs:397-436; SDK pass-through CodexDomainTypes.swift:720,749-750; CodexTurnSequences.swift:329-331. +- 検証時の訂正: Minor: the post-terminal protection is BackendReviewEventMailbox.terminal (append guard in CodexReviewBackend.swift), not the ReviewBackendEventSession `finished` flag — that flag (ReviewBackendEventSession.swift:44,124) is only set by finish()/abandon(), not on terminal emit (receive() at :133-137 returns without setting finished). The protective effect is as described. Also, upstream ordering makes the latent bug hard to hit: unload requires the thread to be idle (turn already completed), so turn/completed reaches the mailbox first in the observed server code. +- 失敗トレース: Latent path (guarded today): 1) review turn completes → adapter emits .completed (AppServerCodexReviewBackend.swift:553-554,590-607) → mailbox.terminal=.finished (CodexReviewBackend.swift append). 2) app-server later unloads the idle review thread → thread/closed (thread_lifecycle.rs:429-434) → SDK forwards .closed (CodexDomainTypes.swift:749-750) → adapter converts to .failed("Review thread closed.") (AppServerCodexReviewBackend.swift:583-584) → dropped only by the mailbox terminal guard. If .closed ever reaches the mailbox before the terminal (guard removed, or ordering change), the completed review is recorded failed. The mapping itself (unload → user-visible failure) is the confirmed protocol mismatch. +- 修正の方向: Adapter classifies .closed as an interruption eligible for the existing restart path (or a distinct terminal reason), not a failure — ultimately fixed by the SDK modeling .closed as non-terminal unload. + +### `arch-host-target-test-only` — CodexReviewHost + DirectCodexReviewStoreBackend are production code with only test callers, duplicating Live backend adaptation logic + +**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 class and test the store against a CodexReviewTesting fake of CodexReviewBackend, or promote it to the real headless composition root sharing mapping helpers with the Live backend — one owner for backend→store adaptation. + +### `arch-testing-forwarder-chains` — ~100 ForTesting accessors mechanically forwarded through three layers (scroll view → chat-log target → transport VC) + +**CONFIRMED / medium / test-gap** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift:351` + +ReviewMonitorCodexChatLogTarget devotes ~510 of its 861 lines to a DEBUG extension forwarding every test probe to logScrollView; ReviewMonitorTransportViewController repeats the list one layer up (~570 lines) prefixed with `log`. Every new leaf capability requires three mechanical wrapper edits — tests lack a direct seam to the leaf render surface, and the noise hides the two types' real contract (bind/clear/render). + +- 証拠: ReviewMonitorCodexChatLogTarget.swift:351-861 (:373-379 example); ReviewMonitorTransportViewController.swift:205-771 (:247-257 same accessors re-wrapped). +- 検証時の訂正: Accessor count is slightly above the finding's "~100": 107 at the target layer and 124 at the VC layer (the VC adds a few of its own probes, e.g. placeholder state :235-245, that are not forwards). Line spans as cited. +- 修正の方向: 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 the active turn by splitting the server error message on " but found " (activeTurnID) and retries on lowercase substrings "no active turn"+"interrupt", sending turnId:"" as a deliberate mismatch probe. These match today's exact format! strings (which already differ between upstream call sites) and are not a contract — any rewording silently breaks cancel. Upstream returns the same 'no active turn' error for both not-yet-activated and already-terminal turns, so cancelling a just-finished turn burns ~250ms of retries then throws an untypeable JSONRPC.Error instead of being treated as already-cancelled. The post-loop `throw CancellationError()` (:496) is unreachable. Root cause is an upstream affordance gap (no structured code / active-turn query); cross-ref conf-error-payload-discarded. + +- 証拠: CodexThreadOperations.swift:465-532 retry loop, activeTurnID parse, isExpectedTurnNotActive; upstream /Users/kn/Dev/codex/codex-rs/app-server/src/request_processors/turn_processor.rs:904,1364-1373 differing message shapes and :1371 terminal-turn same-error path. +- 検証時の訂正: One evidence claim is wrong: `turnId:""` is not a 'deliberate mismatch probe'. Upstream treats empty turn_id as a startup interrupt (turn_processor.rs:1352,:1358,:1389) that bypasses the active-turn check and interrupts whatever is running, succeeding without error; CodexKit only sends "" when turnID is nil (CodexThreadOperations.swift:507-510, e.g. cancelActiveTurn(expectedTurnID: nil) :233-243). The ' but found ' discovery fires only when a non-empty stale turnID is sent. Core finding (string coupling, wasted retry on terminal turns, unreachable :496) stands. +- 失敗トレース: 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. +- 修正の方向: Consult router history (hasTerminalTurnEvent) before retrying so locally-known-terminal turns are idempotent successful cancels; resolve the active turn via thread/read instead of the error probe; push upstream for structured error codes, and key retry/redirect on typed error data once error.data is carried through. + +### `ask-thread-closed-terminal` — thread/closed (idle unload, resumable) is treated as the terminal thread event, permanently finishing subscribers and ending review sequences without a result + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:333` + +Upstream emits thread/closed when an idle, subscriber-less thread is unloaded from memory — resumable, not terminal. The router finishes all thread subscribers on .closed and isTerminalThreadEvent is true only for .closed; new subscribers finish immediately while .closed is in the current generation. CodexReviewProgressSequence returns nil on .closed without ever yielding a terminal phase, CodexReviewEventSequence reports .closed as terminal, and CodexResponseCollector throws .transportClosed when a stream ends without turn/completed even though the transport is fine. Consumers are forced to invent semantics (see arch-closed-maps-to-failed) and passive thread.events observers see their loop end on server GC of an idle thread. + +- 証拠: Router :333-335 finishThreadSubscribers on .closed, :522-527 isTerminalThreadEvent; CodexTurnSequences.swift:155-157,:238-239,:329-331,:344-345,:490; upstream /Users/kn/Dev/codex/codex-rs/app-server/src/request_processors/thread_lifecycle.rs:397-435 idle-unload emits ThreadClosedNotification{thread_id} only. +- 失敗トレース: 1) Consumer holds a passive `for await event in thread.events` observer (CodexThreadOperations.swift:9-27) on a thread that goes idle with no server-side subscribers. 2) Upstream unloads it and emits thread/closed (thread_lifecycle.rs:397-435) — thread still resumable via thread/resume (common.rs:488). 3) Router decodes .closed (CodexAppServerNotificationRouter.swift:822-823) and permanently finishes every thread subscriber (:333-335). 4) Any new subscriber for that thread finishes immediately because .closed sits in the current generation (:378-381,:388-392,:522-527). 5) A CodexReviewProgressSequence consumer's loop ends with no terminal phase ever yielded (CodexTurnSequences.swift:329-331), so the review appears to vanish rather than remain resumable — SDK invents terminal-ness the protocol does not promise. +- 修正の方向: Router owns thread-stream lifetime: model .closed as a non-terminal 'unloaded' state (subscribers stay open or finish with a distinct reason), give 'ended without terminal event' its own error instead of transportClosed, and keep terminal-ness tied to connection close and explicit archive/delete. + +### `conf-command-delta-clobbers-item` — commandExecution/fileChange output deltas replace the transcript item, wiping command text and prior output during streaming + +**CONFIRMED / medium / bug** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:887` + +itemUpdate builds a CodexThreadItem with content .command(.init(command: "", output: )) and CodexTranscriptAccumulator.upsert does items[index] = item, wholesale-replacing the item/started item (which carried the real command/cwd) — transcript consumers see the command string vanish and output show only the LAST delta chunk instead of the concatenation upstream guarantees. item/completed later restores the item, so corruption is transient but visible for the whole command runtime. CodexDataKit independently compensates with accumulatesOutputDeltas merge logic — the recurrence signal that the merge invariant has no owner in the shared accumulator. Cross-ref ask-delta-random-item-identity (identity half of the same function). + +- 証拠: CodexAppServerNotificationRouter.swift:887-905; CodexTurnSequences.swift:667-668 replace-on-upsert; upstream concatenation contract app-server README:1399, item.rs:1400-1408; compensator CodexModel.swift:1193-1197. +- 検証時の訂正: Citation nit: upstream README's explicit 'concatenate delta values' sentence at :1399 is in the agentMessage section; for commandExecution the chunk-append semantics follow from README:697-705 (zero or more outputDelta chunks for the same item id) rather than an explicit concatenation sentence. Does not change the conclusion: showing only the last chunk and erasing command/cwd is wrong under either reading. +- 失敗トレース: (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 item-merge semantics: typed partial updates (append output, never replace command/cwd/status) in one place consumed by both the accumulator and DataKit, deleting per-layer compensators. + +### `conf-error-payload-discarded` — CodexErrorInfo and JSON-RPC error.data are dropped everywhere, forcing string-matching on error messages + +**CONFIRMED / medium / coverage-gap** — `CodexKit:Sources/CodexAppServerKit/AppServerRequests.swift:1140` + +Upstream TurnError = {message, codexErrorInfo?, additionalDetails?} with a rich enum (contextWindowExceeded, usageLimitExceeded, activeTurnNotSteerable{turnKind}, ...), and steer/interrupt failures serialize the full TurnError into JSON-RPC error.data. CodexKit decodes only {message} and the transport discards error.data entirely. Consequences: consumers cannot distinguish error classes without parsing prose; CodexKit itself must string-match upstream error text for interrupt retry/redirect (cross-ref ask-interrupt-error-string-parsing); `error` notifications' structured payload is invisible (cross-ref cov-error-warning-untyped). + +- 証拠: AppServerRequests.swift:1140-1147 message-only; AppServerProcessTransport.swift:251-258 data never touched; upstream thread_data.rs:266-275 TurnError, shared.rs:63-111 CodexErrorInfo, turn_processor.rs:910-941 TurnError in error.data. +- 検証時の訂正: Citation nits: CodexErrorInfo enum body is shared.rs:~92-133 (cited 63-111 — that range covers the doc comment/TurnError vicinity); TurnError-into-error.data is turn_processor.rs:921-951 (cited 910-941, same code block); the file is app-server/src/request_processors/turn_processor.rs. Substance accurate. +- 修正の方向: Transport carries error.data through JSONRPC.Error; decode TurnError/CodexErrorInfo once (with .other catch-all) so flow control and consumer UX key off typed codes. + +### `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 partial decode; upstream account.rs:505-515 sparse-merge doc, :289-295 dropped fields. +- 検証時の訂正: Citation nits: the sparse-merge doc is account.rs:495-506 (cited 505-515); of the dropped fields, rateLimitResetCredits is at account.rs:~294 in GetAccountRateLimitsResponse while limitName/credits/individualLimit (plus rateLimitReachedType, not listed in the finding) are RateLimitSnapshot fields at account.rs:516-529 (finding cited :289-295 for all). Substance accurate. +- 失敗トレース: (1) Client reads full snapshot: primary+secondary windows and planType populated; (2) server later emits account/rateLimits/updated carrying only {limitId: "codex", primary: {...}} — legal per the sparse-update contract (account.rs:495-506, all RateLimitSnapshot fields Optional :516-529); (3) CodexAppServer.accountEvent decodes it and constructs a brand-new CodexRateLimits from only that payload (CodexAppServer.swift:1166-1182); (4) a consumer treating .rateLimitsUpdated as current state (the natural reading of an event named 'updated' with no partial-delta typing) now observes secondary == nil and planType == nil — previously observed values read as cleared, violating the upstream 'does not clear a previously observed value' contract; no SDK API exposes the previous snapshot to merge against. +- 修正の方向: 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 decodeThreadEvent default → .unknown (CodexAppServerNotificationRouter.swift:824-826); unmanaged handler tasks AppServerProcessTransport.swift:234-245,289-313; upstream v2/notification.rs:50-56 ServerRequestResolvedNotification, abort sites bespoke_event_handling.rs:154,184,1048. +- 検証時の訂正: Citation nit: ServerRequestResolvedNotification is at v2/notification.rs:52-57 (finding cited :50-56); abort call sites are :154, :184, :1047-1049. Substance accurate. +- 修正の方向: 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 decodes four notifications that do not exist upstream (turn/failed, turn/cancelled, item/updated, agent/message); dead paths drive live machinery and consumer fixtures + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:661` + +None of these methods exist in upstream v2 ServerNotification (common.rs:1613-1710; grep: 0 hits). Consequences: (1) public .turnFailed can never fire — real failures arrive only as turn/completed status=failed, so consumers switching on .turnFailed see nothing, and generation-boundary logic carries dead .turnFailed branches (:567-570); (2) the agent/message path is the sole producer of the fallback-ID system (CodexAgentMessageFallbackID, upsert(replacingFallbackID:), CodexModel text-signature dedup) — dead protocol surface driving live complexity; (3) consumer previews are built on item/updated (ReviewMonitorPreviewAppServerRuntime.swift:563) and deprecated item/fileChange/outputDelta (:632), and SDK tests feed agent/message and turn/failed fixtures, so fakes exercise flows no real server produces. Cross-ref: test-fictional-turn-failed-pins-phase-contract, dk-optional-delta-id-workaround-layer, conf-interrupted-is-failure. + +- 証拠: Router :661-662,:668,:698-706,:748-751,:758-762,:788-796 dead method cases; upstream /Users/kn/Dev/codex/codex-rs/app-server-protocol/src/protocol/common.rs:1613-1717 has only TurnStarted/TurnCompleted for turn lifecycle; grep for the four quoted method strings in app-server-protocol/src returns nothing; fallback machinery CodexDomainTypes.swift:2234-2249, CodexTurnSequences.swift:647-693; fixtures CodexAppServerKitTests.swift:3541,3575,3662, CodexDataKitTests.swift:8091. +- 検証時の訂正: One sub-claim is imprecise: agent/message is not the SOLE producer of the fallback-ID system. Fallback IDs are also minted by item/agentMessage/delta events whose itemID is absent (router AgentMessageDeltaPayload.itemID optional, CodexAppServerNotificationRouter.swift:1084-1096; CodexTurnSequences.swift:675-689 append(delta,fallbackItemID:)). agent/message is the sole producer of .message events that drive upsert(replacingFallbackID:) resolution. The thrust survives because upstream requires item_id on agent-message deltas (item.rs:1333-1338 AgentMessageDeltaNotification.item_id: String, non-optional), so the delta-side fallback path is equally dead against a real v2 server. Also minor: dead .turnFailed generation-boundary branches are at router :485, :549, :574-575 (finding cited :567-570). +- 失敗トレース: Consumer switches on public CodexThreadEvent.turnFailed (e.g. CodexReviewKit:Sources/CodexReviewAppServer expects failure events): (1) real server fails a turn -> emits only turn/completed with turn.status=failed (upstream common.rs:1636 TurnCompleted is the only turn-terminal notification; TurnStatus turn.rs:29-35); (2) CodexKit router decodes it as .turnCompleted(response) (CodexAppServerNotificationRouter.swift:746-747), never .turnFailed (:748-752 requires method turn/failed|turn/cancelled which no server sends, grep 0 in app-server-protocol/src); (3) the consumer's .turnFailed branch never executes; failure must be re-derived from response.status/errorMessage. Meanwhile SDK tests and previews pass because their fakes emit the nonexistent methods (CodexAppServerKitTests.swift:3541; ReviewMonitorPreviewAppServerRuntime.swift:562), so fakes diverge from any real server. +- 修正の方向: Router owns the wire-method surface: delete nonexistent-method branches (or synthesize .turnFailed from turn/completed+failed so the typed event means what it says), make delta itemId required, delete the fallback-ID machinery they feed, and migrate preview/test fixtures to real v2 notifications. + +### `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 has a bespoke routing list for exactly these four methods, but decodeThreadEvent/decodeTurnEvent have no cases for them, so they surface only as .unknown — consumers drop them (AppServerCodexReviewBackend unknownEvents returns []), making 'model retrying' (ErrorNotification.will_retry, the only transient-retry signal) and deprecation warnings (incl. thread/rollback removal) invisible; willRetry is not decoded anywhere. Since v2 ErrorNotification always carries threadId/turnId, the unscoped broadcast branch (:274-286) is unreachable. Related leniency: turnResult() decodes turn/completed with try? — an undecodable payload yields a CodexResponse with empty turnID/nil status that CodexResponseCollector treats as success; TurnError.codex_error_info/additional_details are dropped (cross-ref conf-error-payload-discarded). + +- 証拠: Router :556-563 isUnscopedDiagnosticNotification, :274-286 broadcast, decode switches :657-731,:743-827 no cases → .unknown; :1011-1026 turnResult try?; upstream notification.rs:41-48 ErrorNotification, thread_data.rs:270-276 TurnError; consumer AppServerCodexReviewBackend.swift:585-586,645-650. +- 検証時の訂正: One sub-claim is overbroad: the unscoped broadcast branch (:274-286) is unreachable only for the "error" method (ErrorNotification.thread_id/turn_id required, notification.rs:41-48). It IS reachable and load-bearing for warning (thread_id: Option, notification.rs:21-26), deprecationNotice (no thread_id field at all, notification.rs:11-16), and configWarning. The rest of the finding stands as stated. +- 修正の方向: 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 only the two commandExecution/fileChange approvals; every other server request gets emptyResult() = {}. Upstream requires `answers` on ToolRequestUserInputResponse and `permissions` on PermissionsRequestApprovalResponse — {} fails serde, upstream error!-logs and substitutes empty answers/grant, so every such reply is a logged contract violation degrading to deny. No typed params or decision enums exist even for the approvals the SDK special-cases; item/tool/requestUserInput, item/permissions/requestApproval, mcpServer/elicitation/request have zero CodexKit references, yet Configuration docs claim support for these flows and experimentalApi:true keeps experimental requests in play. Cross-ref conf-server-request-resolved-ignored (stale-request cancellation) and test-no-server-request-injection. + +- 証拠: 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` — .closed unload notification terminalizes running turns as .completed, fabricating success in the DataKit model + +**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexDataKit/CodexModel.swift:1258` + +thread/closed says nothing about turn outcomes (unload only, resumable), yet CodexChat.apply(.closed) sets status .notLoaded and force-marks every non-terminal turn and its items .completed with a synthesized completedAt; same fabrication via terminalTurnStatus for statusChanged(.idle/.notLoaded) (:1692-1701). Mid-turn unload records a success that never happened until a later snapshot contradicts it. Cross-ref ask-thread-closed-terminal (router half of the same protocol misreading). + +- 証拠: CodexModel.swift:1258-1264 `case .closed: setStatus(.notLoaded); terminalizeActiveTurns(status: .completed, ...)`; :1692-1701 mapping; upstream thread.rs:1467-1469 ThreadClosedNotification{thread_id} only. +- 検証時の訂正: One premise is imprecise: upstream never unloads mid-turn — the unload loop skips while AgentStatus::Running (thread_lifecycle.rs:351-354), so 'mid-turn unload' does not happen server-side. The fabrication is reachable only when the client model still holds a non-terminal turn at .closed/.idle time (missed or out-of-order turn/completed, resumed stale state); the statusChanged(.idle/.notLoaded) mapping at :1692-1701 is the more reachable path. The mismatch itself (assigning a terminal .completed outcome to a pure unload/status notification) is confirmed. +- 失敗トレース: 1) Client model holds turn T with status inProgress (e.g. terminal turn/completed event missed after resubscribe, or resumed thread seeded with a stale running turn). 2) Server unloads the idle thread and broadcasts thread/closed {threadId} (thread_lifecycle.rs:429-434). 3) Router decodes it to .closed (CodexAppServerNotificationRouter.swift:822-823). 4) CodexChat.apply hits case .closed (CodexModel.swift:1258-1264) -> terminalizeActiveTurns(status: .completed) (:1669-1690) sets T.status = .completed and stamps its items completedAt = now (:1703-1733). 5) T's true upstream outcome may be Interrupted or Failed (v2/turn.rs:30-35); the model now records a success the server never declared, until a later snapshot contradicts it. +- 修正の方向: 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 > 0; workspace refresh filters before deciding; paged revalidation and insert offset/limit constraints live elsewhere. Concrete asymmetry: insert skips the usesServerOwnedOrdering check its siblings enforce, so under server-owned ordering a new chat is prepended locally (sortedItems deliberately doesn't sort recencyAt plans) — wrong for .forward order until next refresh. The four recent fixes (c58c47b, b141853, f350209, 33cd29e) all patched this guard area. + +- 証拠: CodexFetchRequest.swift:818-827 insert path, :829-851 archive, :887-911 remove, :913-957 workspace, :1116-1132 paged, :1037-1042 canInsertLiveModel, :1066-1076 two guard properties; CodexModelContext.swift:2442-2446 recencyAt skip. +- 検証時の訂正: The commit-history claim is imprecise: c58c47b, 33cd29e, f350209 patched predicate lowering/local-semantics in CodexThreadQueryPlan.swift and b141853 patched sort signatures — i.e. the inputs feeding these guards (isComplete -> membershipRequiresServerRefresh, matches()), not the registration-handler guard lines themselves. Same responsibility neighborhood, different layer. +- 修正の方向: 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 String?, so DataKit maintains a compensation subsystem: scoped fallback IDs, promotedMessageDeltaKeyByFallbackKey, promoteFallbackMessageDeltaItem, text-equality signature matching (CodexFallbackAgentMessageSignature) in three separate places, plus itemsByReplacingFallbackAgentMessageItems. Text-equality matching is semantically unsound (identical messages in one turn alias) and every new merge path must remember it — the drift class that produced 2bfef6e/25c178a/503ec9a. Cross-ref cov-dead-compat-notifications (the dead agent/message path is the only producer of nil IDs). + +- 証拠: 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; everything else hits preconditionFailure in descriptor init, section descriptors, validation, and lowering. CodexQuery.update() computes querySignature (which lowers the predicate) on every SwiftUI update, so e.g. `#Predicate { $0.createdAt > date }` crashes during render. SwiftData throws for unsupported predicates; here 'compiles so it works' is a crash, there is no throwing validation API, and the supported grammar is undocumented (README understates as 'not silently treated as app-server sorts'). Fail-fast was deliberate (269f0f9) but has no discoverable contract. + +- 証拠: 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 never observe server-side thread-list changes — the SwiftData-style 'live query' expectation does not hold + +**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; registration is inserted before the async startObservation completes and cancellation is only possible via the handle returned at the end — so cancel-old-then-observe-new (typical view rebinding) races and throws, since 'cancelled' does not synchronously mean 'slot free'. Updates are already multicast, so the single-slot restriction is an internal lifecycle constraint leaking into public API; no NSFetchedResultsController/SwiftData analogue imposes it. Consumer workaround exists (see use-observe-serialization). + +- 証拠: CodexModelContext.swift:804-810 throw, :812-827 slot registered before await; consumer CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift:157-166. +- 検証時の訂正: One nuance: 'cancelled does not synchronously mean slot free' is true only for the in-flight-registration case (Task cancellation); cancelling a fully-established CodexChatObservation handle synchronously releases the slot via releaseChatObservation (CodexModelContext.swift:972-981). The consumer workaround targets exactly the in-flight case, so the finding's substance stands. +- 修正の方向: 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:). If Foundation renames the property or changes the description, it silently returns nil/unstable strings, collapsing comparator distinctions in CodexSortPlanSignature — CodexQuery.update() then reuses the wrong CodexFetchedResults (stale ordering, no error; the failure class b141853 just fixed for key paths). Silent misbehavior contradicts the crash-loudly stance used everywhere else in the file. + +- 証拠: CodexFetchRequest.swift:78-84 Mirror extraction; CodexThreadQueryPlan.swift:174-184 signature includes comparison: String?; commit b141853 precedent. +- 検証時の訂正: Minor precision: String(describing:) of the comparison child is itself layout-dependent even when the label survives; and the collapse today affects only comparator distinctions (path/order are extracted separately), so blast radius is descriptors that differ only in custom comparators. +- 修正の方向: Restrict supported SortDescriptor forms to (keyPath, order) and drop the reflection, or fail fast when the 'comparison' child is absent — signature equality must imply query equivalence without depending on undocumented Foundation layout. + +### `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/refreshAfterMutation/backfill all funnel into load() with mid-flight awaits and no serialization or generation token: a registration-triggered refresh can interleave with a suspended loadNextPage, giving last-writer-wins items with a nextCursor from the losing load (items/cursor disagreement, A→B→A-completed sequences). Additionally, any included-chat revalidation while paged (nextCursor != nil or offset > 0) reloads page 1 non-appending, discarding pages the user scrolled through — NSFetchedResultsController never resets its window this way. + +- 証拠: 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 — server keeps working — documented nowhere on the type; cancelling the task awaiting collect() fires an unstructured detached `Task { try? await turn.interrupt() }` that DOES stop the work, swallows its failure via try?, and can outlive/race server.close(). Since respond(to:) is streamResponse().collect(), plain respond() silently inherits interrupt-on-cancel while iteration does not — the same type answers 'does cancel stop the work' differently per method. Only the README mentions the collect() behavior. Cross-ref ask-cancel-collect-misreported (error type on the same path). + +- 証拠: CodexDomainTypes.swift:1994-2008 detached interrupt, no doc; CodexThreadOperations.swift:23-25,74; router :105-107,138-140 unsubscribe-only; README.md:102-103. +- 修正の方向: One documented cancellation contract on CodexResponseStream: either task-cancel is pure stop-listening (explicit cancel() to interrupt) or interrupt-on-cancel is structural (withTaskCancellationHandler awaiting the interrupt, surfacing failures) — documented on the type. + +### `dx-no-raw-escape-hatch` — No outbound raw JSON-RPC escape hatch: consumers cannot call any upstream method the SDK has not typed yet + +**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexAppServerKit/README.md:486` + +JSON-RPC and AppServerAPI DTOs are package-internal by design and there is no public (method, params) send. Inbound forward-compat is good (CodexRawNotification), but outbound is fully closed: upstream ships weekly with methods CodexKit doesn't type, so a consumer needing one new call must fork or wait. There is exactly one send path (retry + serial lanes), so a raw send decorating it would be cheap and could not drift. + +- 証拠: README.md:484-487 policy; AppServerClient.swift:6 package actor; CodexAppServer.swift:146-148 package appServerClient; grep public send-like API = none; inbound hatch CodexDomainTypes.swift:2502-2519. +- 修正の方向: CodexAppServer exposes a public sendRaw(method:params:) → Data routed through AppServerClient.send (same retry/lane/error mapping). + +### `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 emitServerNotificationJSON(method: "turn/failed") + phase assertion; upstream common.rs:1614-1660 TurnCompleted only, turn.rs:30-35; reachable path CodexModel.swift:1142-1151; revert test without phase assertion CodexDataKitTests.swift:6267-6281. +- 検証時の訂正: Severity adjusted high -> medium: this is a regression-risk test gap (removing the dead routes breaks the only failed-phase pin and leaves the real turn/completed+failed shape unpinned), not wrong behavior a consumer hits today. +- 修正の方向: 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:10258-10430 param structs; ReviewMonitorPreviewAppServerRuntime.swift:733-815 and :715-724 status "cancelled"; Testing encoder CodexAppServerTestRuntime.swift:767-842; package scope AppServerRequests.swift:3,162; upstream turn.rs:30-35; invented status CodexDomainTypes.swift:1841,1854. +- 検証時の訂正: Minor: the 'cancelled' status emission is at ReviewMonitorPreviewAppServerRuntime.swift:717-727 (cited :715-724), and the DataKit param structs start at :10204 (cited :10258, which is the first Encodable notification struct). Substance unchanged. +- 修正の方向: Testing target owns typed notification builders (emitTurnCompleted, emitItemUpdated, emitThreadClosed, ...) built on the same package AppServerAPI types the router decodes — single wire-schema owner, delete every mirror. + +### `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 production API, but JSONRPC.Transport doesn't model server requests (they are internal to AppServerProcessTransport), the fake has zero references to CodexAppServerRequest, and CodexAppServer.testing(transport:) accepts no handler. The only coverage is a shell-script process test at the transport layer; no app developer can deterministically test their approval handler, decision encoding, or UI flow. Cross-ref cov-server-requests-untyped. + +- 証拠: 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. +- 検証時の訂正: One premise weakened: no consumer in CodexReviewKit currently uses the approval-handler API at all, so 'no app developer can deterministically test their approval handler' is a prospective gap for the public API, not an observed consumer workaround in this workspace. +- 修正の方向: 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. + +### `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` + +A review spans two thread IDs; CodexKit exposes associatedThreadIDs/cleanupThreadIDs but no canonical 'which chat does this review belong to'. The backend keeps three dictionaries to route events/cancellations; the store matches run records by comparing both threadID and reviewThreadID strings; the MCP provider picks reviewThreadID ?? threadID. Each layer re-derives the mapping; drift between them is the bug class the prior generation/identity findings predicted. + +- 証拠: AppServerCodexReviewBackend.swift:22-28,360-410; CodexReviewStoreOrderQueries.swift:94-108; CodexReviewMCPServer.swift:174; SDK identity CodexDomainTypes.swift:836-870. +- 検証時の訂正: The claim 'no canonical which-chat mapping' is overstated: CodexReviewIdentity.sourceThreadID/activeTurnThreadID (CodexDomainTypes.swift:639-646) are exactly that accessor pair. The verified gap is narrower — review identity does not flow through the string-typed Run/event plane the consumers operate on, so the three layers re-derive it from raw strings. Also the cited SDK lines 836-870 are CodexReviewSession's mirrors; CodexReviewIdentity itself is at :621-676. +- 修正の方向: CodexReviewIdentity exposes the canonical presentation thread ID and events carry review identity, so consumers key everything off one value. + +### `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 dedup/projection pipeline on every update, re-deriving diffs via UTF16 text comparison. DX evidence that item identity/ordering in updates isn't trustworthy for targeted apply (consistent with dk-update-id-discontinuity), and a per-delta O(items) cost on hot streaming paths. + +- 証拠: ReviewMonitorCodexChatLogSourceProjection.swift:54-101; downstream diffing ReviewMonitorLogProjection.swift:5-100. +- 修正の方向: Once CodexDataKit guarantees stable IDs and ordered updates, apply updates targetedly; until then this is the rational consumer response — fix belongs SDK-side. + +### `use-highlevel-surface-bypass` — Consumer bypasses every high-level SDK review surface (progress/collect/response) and rebuilds them over raw events + +**CONFIRMED / medium / api-design** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:752` + +CodexReviewSession offers progress, collect(), messages, logEntries — none used by the app. The adapter iterates raw session.events and re-implements terminal classification, transcript-output extraction, and cancel semantics, because the high-level surfaces classify interrupted as failure and end silently on .closed (cross-ref conf-interrupted-is-failure, ask-thread-closed-terminal). When the SDK's flagship consumer cannot use its convenience layer, that layer's contract is wrong — the strongest DX evidence in the repo. + +- 証拠: AppServerCodexReviewBackend.swift:752-756 raw events only, :590-614 own terminalEvents/reviewCompletionText; avoided surfaces CodexDomainTypes.swift:893-900, CodexTurnSequences.swift:303-309,482-484. +- 修正の方向: Fix the high-level surfaces' semantics (cancelled≠failed, .closed≠silent end) so the convenience layer is adoptable; the adapter then shrinks to a thin mapping. + +### `use-mcp-refresh-fallback` — MCP log provider does a full model refresh per read with silent catch → 'unavailable' fallback + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift:167` + +To serve review_read/await the provider must refresh(chat, includeTurns: true) on every call (poll-style refetch because the model context doesn't reflect the live review thread), swallow refresh errors returning nil, and fall back to an empty ReviewMCPLogProjection.unavailable. Every MCP read pays a full thread fetch; failures degrade silently. Cross-ref dk-query-not-live-external. + +- 証拠: CodexReviewMCPServer.swift:178-187 refresh + catch { return nil } + guard; fallback :153; ReviewMCPLogProjection.swift:38-51. +- 修正の方向: SDK owns freshness: model context guarantees live review turns are reflected (observation-driven) or exposes a direct transcript-snapshot read by CodexReviewIdentity; the silent fallback becomes an explicit error path. + +### `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 observe() enforces one observation per chat with fire-and-forget cancel (cross-ref dk-single-observation-slot), the consumer must keep the cancelled task referenced and `await previousTask?.value` before re-observing the same chat, per its own apologetic comment — single-consumer, non-idempotent observation lifecycle pushed onto UI code. + +- 証拠: 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` — Cancelling a review without a live handle requires resuming the whole session first + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:466` + +CodexKit exposes cancel only on live handles; turn/interrupt is package-only. When the in-memory registry misses (post-restart/cleanup), the consumer calls appServer.resumeReview(identity) — constructing a full session with event thread — solely to call .cancel(). The adjacent reviewEventSession(for:) fallback even manufactures an empty session whose cancelReview returns nil, silently routing here. + +- 証拠: AppServerCodexReviewBackend.swift:466-470 resume-then-cancel, :349-358 manufactured session; SDK CodexThreadOperations.swift:448-466 package interrupt() only. +- 修正の方向: CodexAppServerKit exposes public cancel-by-identity (interruptTurn(threadID:turnID:) or cancel(CodexReviewIdentity)); the resume-to-cancel dance and manufactured-session fallback both die. + +### `use-review-output-location` — 'Review completed without review output' failure synthesized at two layers with a triple fallback chain for where the output lives + +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:610` + +CodexResponse gives no contract for where a review's final output lives. The backend probes transcript.reviewOutputText ?? finalAnswer ?? transcript.finalAnswer and synthesizes a failure when empty; the store repeats the same synthesis independently for stream-finished-without-terminal and empty-finalReview; the MCP projection adds a fourth fallback (finalReview ?? lastAssistantMessageText). 5+ fix commits circle this question (8e3ec41, 6e34054, cf78964, e7eac9e, 1ef636f). + +- 証拠: AppServerCodexReviewBackend.swift:610-614 fallback chain, :602-604 synthesized failure; same string CodexReviewStoreReviews.swift:688,709,856-863; MCP ReviewMCPLogProjection.swift:91-94. +- 修正の方向: CodexAppServerKit guarantees a single review-output field on the terminal CodexResponse for review turns (finalizedTranscript already merges exitedReviewMode text — finish that ownership). + +### `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 lifecycle/health surface, so the host keeps an accountEvents() subscription alive partly so its throw becomes the 'server died' signal, then runs full runtime teardown from that catch block. If account notifications were consumed elsewhere or the stream ended benignly, death detection silently changes behavior. + +- 証拠: LiveCodexReviewStoreBackend.swift:1184-1188 catch → markRuntimeFailedAfterNotificationStreamError, teardown :1200-1229; SDK public surface (CodexAppServer.swift:220-867) has close() but no state/termination signal. +- 修正の方向: CodexAppServerKit exposes a lifecycle surface (state AsyncStream or terminated continuation) so hosts subscribe to death explicitly. + +### `use-subscription-generation-guards` — Store worker builds its own subscription-ID generations to filter stale backend events across restarts + +**PLAUSIBLE / medium / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:582` + +Because a superseded attempt's mailbox stream doesn't terminate when a review is restarted after a network outage, the worker maintains monotonic subscription IDs (ReviewWorkerEventSource) and double-guards every input by subscriptionID and attemptID equality. The generation-boundary invariant the prior audit flagged inside CodexKit is reproduced consumer-side: switching generations is again the call-site's cursor dance rather than a producer-owned boundary. + +- 証拠: CodexReviewStoreReviews.swift:582-585,596,609 guards, :1027-1029 shouldConsumeEvent, :1176-1280 generation counter. +- 検証時の訂正: Superseded mailboxes do terminate (abandon()/fail() at CodexReviewBackend.swift:100-115); the actual gap is that supersession is signalled as a plain .finished indistinguishable from normal completion, forcing the worker to pre-emptively unsubscribe and filter by generation instead of reacting to a typed supersede terminal. +- 修正の方向: Backend attempt streams (ultimately SDK review sessions) terminate deterministically when superseded/abandoned so consumers subscribe to exactly one live generation instead of filtering. + +### `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 enteredReviewMode/exitedReviewMode to review-marker: constants and prefixes other kinds with kind.rawValue. Snapshot items bypass the model, so the consumer re-implements the marker constants verbatim — but without the kind-prefix branch, so snapshot-derived and model-derived block IDs diverge for non-marker items. Two unversioned copies of one identity invariant across a repo boundary. + +- 証拠: ReviewMonitorCodexChatLogProjection.swift:785-794; SDK private twin CodexModel.swift:512-526 incl. kind-prefix branch. +- 検証時の訂正: Severity: the incomplete copy is only exercised by test/preview-style snapshot renders today; no production call site feeds CodexThreadSnapshotLogItem, so the ID divergence is latent rather than user-visible. The two-unversioned-copies maintenance hazard stands. +- 修正の方向: CodexDataKit exposes the semantic/model item ID mapping publicly (or model-normalized projections of CodexThreadSnapshot), making the consumer copy deletable. + + +## Appendix B: 未検証 low findings(36 件) + +- `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` [protocol-mismatch] CodexModel decode sorts service tiers lexicographically and merges the deprecated additionalSpeedTiers field — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2695` + - supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTiers ids)).sorted() — keeps consuming the deprecated field and destroys server-provided ordering by lexicographic sort (the mistake CodexKit correctly avoids for supportedReasoningEfforts). ModelServiceTier name/description are dropped, so UIs can only show 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's Permissions enum can also encode {type:"profile", id} via .profileSelection (reachable through package CodexThreadPermissions.profileSelection), which serde would reject as -32602. The public .profile(id:) path conforms; the object arm is a latent wire break with no public producer. +- `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 capabilities knob. Every consumer is silently opted into upstream's unstable experimental surface (the flag also changes which server→client requests may arrive). Public stable-looking CodexThread.listTurns() is backed by experimental thread/turns/list — it works only because of the hardcoded flag and can break without semver signal. optOutNotificationMethods is likewise 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 the wire, yet the consumer branches on it, encoding a belief that cancel-vs-interrupt is a server distinction. The alias table is a second protocol-vocabulary source: a future upstream status would be absorbed or misclassified instead of surfacing as .unknown. Smaller instance: CodexThreadItem.Kind invents .diagnostic/.error while upstream's real hookPrompt variant has no typed kind. Cross-ref conf-interrupted-is-failure, test-handrolled-notification-schemas-drift. +- `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-fake-cancel-in-flight-returns-success` [protocol-mismatch] Fake send never throws CancellationError for a cancelled in-flight request; the real transport throws and drops the response — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1094` + - When a task awaiting fake send() is cancelled at a gate, cancelWaiter resumes normally and send() returns the queued response as success; the real transport resumes the pending continuation with CancellationError and discards the response. The 'plain unshielded request cancelled in flight throws and its response is never observed' contract cannot be expressed against the fake, which inverts the outcome to success. SDK-internal sites are shielded (Task.detached), limiting current impact. +- `test-store-invented-error-code` [protocol-mismatch] Test thread store returns invented error code -32004 for missing threads; the real server has no such code — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:126` + - resumeThreadResponse/readThreadResponse/listThreadTurnsResponse throw responseError(code: -32004); upstream codes are -32600/-32601/-32602/-32603/-32001 — no -32004. Error-classification logic validated against the fake pins a code the real server never emits. +- `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. From 899a87dec11f8d5e6b159ab89096b9615dc4c408 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:51:17 +0900 Subject: [PATCH 02/62] docs(audit): correct CodexKit design findings --- Docs/design-audit-2026-07-10.md | 574 +++++++++++++++----------------- 1 file changed, 275 insertions(+), 299 deletions(-) diff --git a/Docs/design-audit-2026-07-10.md b/Docs/design-audit-2026-07-10.md index aa4f3fc..1b78032 100644 --- a/Docs/design-audit-2026-07-10.md +++ b/Docs/design-audit-2026-07-10.md @@ -1,206 +1,222 @@ -# CodexKit / CodexReviewKit 設計・API 監査(2026-07-10) +# CodexKit / CodexReviewKit 設計・API 監査(2026-07-10) | 項目 | 内容 | |---|---| -| 対象 | CodexKit `3f6216c`(CodexAppServerKit / CodexDataKit / CodexAppServerKitTesting)、CodexReviewKit `6c1b432` | -| 一次情報 | upstream codex `8347b8d`(/Users/kn/Dev/codex)、openai-python `2.43.0`(SDK ergonomics ベンチマーク) | -| 方法 | multi-agent 監査: 7 mapper(AppServerKit / DataKit / Testing / upstream protocol / openai-python / 消費側 workaround / CRK 構造)+ 3 cross 分析(coverage / conformance / DX)→ 重複統合(raw 130 → canonical 89)→ 領域別 adversarial 検証 | -| 検証ステータス | high+medium 53 件を検証: **CONFIRMED 50 / PLAUSIBLE 1 / REFUTED 2**。low 36 件は未検証(Appendix B) | +| 対象 | 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 | -読み方: 各 finding は Appendix A に id 付きで詳細(証拠 file:line、検証トレース)を収録。本文はクラスタ単位の構造分析。パス表記は `CK:` = CodexKit(`dependencies/CodexKit`)、`CRK:` = 本 repo、`UP:` = upstream codex。 +読み方: 本文はクラスタ単位の判断、Appendix A は id 付きの証拠と failure trace である。パス表記は `CK:` = CodexKit、`CRK:` = 本 repo、`UP:` = upstream codex。severity は実装の存在ではなく、確認できた到達性と利用者影響で再評価した。 ## 1. 結論 -PR #16(2026-07-02)時点の 3 弱境界のうち 2.5 は解消済み: +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 を担う。 -1. **generation 境界 — 解消**。`withThreadEventGeneration` が turn/start・compact・review/start・resume の cursor 切替を所有し、`inferredCurrentGenerationEvents` は削除済み。owner レベルのテストで pin されている。 -2. **observation multicast — 解消**。`ThreadEventPump` が model mutation を所有し、`CodexAsyncStreamRelay` が per-subscriber broadcast を行う。 -3. **item identity — 形を変え残存**。`CodexMessageDelta.itemID` は依然 optional(upstream は required)で、fallback-ID/text-dedup 機構は実在しない `agent/message` decode が唯一の駆動源(→ `dk-optional-delta-id-workaround-layer`, `cov-dead-compat-notifications`)。 +現状は単一の「upstream v2 終端契約の誤読」には集約できない。確認できた弱境界は次の 4 群である。 -**現存する最大の欠陥は「upstream v2 終端契約の誤読」1 点に集約される。** この誤読が SDK 全層(router / turn sequences / DataKit phase)に染み出し、その代償として旗艦消費者である本 repo が SDK の高レベル review API(`progress` / `collect()` / `response`)を**一切使わず** raw events 上に終端分類を再実装している(`use-highlevel-surface-bypass`)。SDK の便利層を SDK 作者自身が使えないことが、この層の契約が誤っていることの最強の証拠である。 +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 を持つ。 -## 2. upstream 契約の一次事実(全て codex-rs 現物で確認済み) +CRK は raw JSON-RPC を再実装していない。public typed surface の `CodexReviewSession.events` と `cancel()` を使い、terminal aggregate の `progress` / `collect()` / `response` は使っていない(`use-highlevel-surface-bypass`)。これは aggregate 層の outcome/output 契約に摩擦がある証拠だが、SDK の高水準 API 全体が利用不能という証拠ではない。 -| protocol 事実 | 根拠(UP: codex-rs) | CodexKit の現状 | +`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 への含意 | |---|---|---| -| turn の terminal は **`turn/completed` のみ**。結果は payload の `turn.status`(completed / interrupted / failed / inProgress) | `app-server-protocol/src/protocol/v2/turn.rs:30-35, 392-395` | `turn/failed`・`turn/cancelled` を decode(`CK:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:661,748`)— upstream 全履歴に不存在 | -| `interrupted` はユーザー中断の**正常終端**(error なし) | turn_processor の interrupt 経路 | `CodexTurnStatus.isFailure` が interrupted/cancelled を失敗扱い(`CK:Sources/CodexAppServerKit/CodexDomainTypes.swift:1878`) | -| `thread/closed` は **idle unload 通知(再開可)**。終端ではない | `app-server/src/request_processors/thread_lifecycle.rs:406-436` | router が全 subscriber を終端、DataKit は走行中 turn を `.completed` に捏造(`CK:Sources/CodexDataKit/CodexModel.swift:1258`) | -| `item/updated`・`agent/message` という notification は存在しない | `protocol/common.rs:1613-1710`(grep 0 件) | 両方 decode。`agent/message` が fallback-ID subsystem の唯一の駆動源 | -| client→server の login request は `account/login/start` / `account/login/cancel` のみ | `protocol/common.rs:1001-1008` | `account/login/complete` + `nativeWebAuthentication` は **invented**(`git log --all -S` 空) | -| `thread/rollback` は "will be removed soon" | protocol 定義の deprecation 注記 | review restart / `rollback(turnCount:)` / `.revertTranscript` の 3 public 挙動が依存 | -| `account/rateLimits/updated` は sparse な rolling update(merge 前提) | notification 定義の doc | 置換適用(merge owner 不在)で secondary/planType が消える | -| server→client request(userInput / permissions)は typed 応答必須 | `ToolRequestUserInputResponse` 等の serde | デフォルトハンドラの `{}` 応答は upstream 側 serde 失敗 → error ログ付き deny 降格 | - -## 3. Owner map — SDK 側の弱境界(現状 → あるべき姿) - -| 責務 | 現状 owner | 問題 | あるべき owner | +| 現行 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 終端の意味論 | `CodexTurnStatus.isFailure` + 各層の独自解釈 | interrupted=失敗の誤読が collect/progress/DataKit phase に増殖 | `CodexTurnStatus` 1 箇所での 3 値終端(成功 / 中断 / 失敗) | -| thread lifecycle | router(closed=終端と誤読) | unload を終端化、subscriber 即終了、DataKit が成功を捏造 | router が closed を「unload・再購読可」として扱う | -| エラー型 | **不在** — package `JSONRPC.Error` が素通り | 型で catch 不能。`CodexAppServerError` の 3 case は dead。`error.data`(CodexErrorInfo)全破棄 | send boundary 1 箇所での public typed error へのマップ | -| 資源解放 | **不在** — deinit ゼロ | router history 無限成長、close() 忘れで codex 子プロセス孤児化 | terminal turn 後の履歴 prune + deinit backstop | -| fetch mutation 戦略(DataKit) | **不在** — insert/archive/revalidate/remove/refresh が各自判断 | `fix(data)` 9 連続の根。insert だけ `usesServerOwnedOrdering` を見ない非対称が現存 | 単一の per-mutation strategy 型 | -| wire fixture | **不在** — SDK tests / Testing target / CRK preview の 3 箇所で手書き | 実在しない `turn/failed`・status `"cancelled"` が fixture に混入済み | Testing target が typed emitter を提供 | +| 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 map(要点) +### CodexReviewKit -健全な境界(維持すべきもの): +維持すべき owner: -- domain core(`CodexReviewKit` target)は transport 非依存(import scan で確認: Foundation/Observation/ObservationBridge/Network のみ) -- UI target は CodexAppServerKit を import しない。preview は `CodexAppServerTestRuntime`(transport 層)で fake し、本番 data flow を保存 -- review event の generation/identity は `ReviewWorkerEventSource`(単調増加 subscription ID + attemptID gating)で構造的に所有 — PR #16 型の cursor dance をこの repo では解消済み -- MCP session lifecycle は `closeSession` 1 経路に集約。chat-log render は純粋な再投影 + document diff +- 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/auth teardown が owner 不在**: `LiveCodexReviewStoreBackend` の 9 mutable fields を 7 つの exit path が手動 reset(`arch-login-state-no-owner`、repo 内で最弱) -- attemptID identity が 3 層で `"attempt-1"` に fallback し、lookup 経路の get-or-create が registry を clobber(`arch-attempt-id-fallback`) -- source/review の dual-thread identity を 3 層が独立に再導出(`use-dual-thread-identity`) -- `CodexReviewHost` + `DirectCodexReviewStoreBackend` はテスト専用の重複 production code(copy-drift 実在: `arch-host-target-test-only`) +- 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. DX 評価(openai-python の 17 契約をベンチマーク) +## 4. Consumer-action based DX 評価 -**CodexAppServerKit**: 層設計は優秀(transport actor → per-thread serial lane + overload retry → replay 付き router → 値型 domain surface)。寛容 decode(`.unknown` / `rawPayload` 保持)、-32001 限定の jittered retry、fail-fast init、再生可能 stream、tri-state config patch、transport 層 testing product はベンチマーク水準を満たす。弱いのは異常系一式: +外部 SDK の点数や「17 契約」は証拠表を作っていないため評価根拠にしない。ここでは、CRK が public API だけで start / observe / cancel / close / recover を完遂できるかで評価する。 -- エラー: 消費者が型で catch できない(CRK に typed catch 0 件、`localizedDescription` 還元 ~30 箇所) -- タイムアウト: init handshake・全 request・`collect()` に deadline なし -- キャンセル: task cancel が `transportClosed` に化ける。同じ `CodexResponseStream` で iteration break(server 継続)と collect() 中 cancel(server 中断)の意味論が割れており、README 以外に文書なし -- `turn/interrupt` の正しさがエラーメッセージ文字列 parse(`" but found "` split)に依存 +**CodexAppServerKit** は transport actor、per-thread serial lane、overload retry、replay router、`.unknown` + raw payload 保持を備え、正常系の層分離は明快である。弱いのは境界失敗時の consumer action である。 -**CodexDataKit**: SwiftData アナロジーは object identity については誠実(fetch は identity 安定な live `@Observable` model を in-place mutate)だが、4 点で開発者を裏切る: +- 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 が必要である。 -1. **ライブではない** — `@CodexQuery` は外部変更(CLI での thread 作成・rename・archive)を観測しない。upstream の `thread/*` notification の ingestion が無い -2. `#Predicate` は 5-key whitelist で、外すと **SwiftUI update() 内で `preconditionFailure`**(SwiftData は throw) -3. `isArchived` に触れない predicate に `archived == false` が**暗黙注入**される(文書化なし) -4. `includePendingChanges` の意味が SwiftData と別物で、公開 mutable フィールドがあるのに `save()` が無い +**CodexDataKit** は identity-stable な `@Observable` model を in-place 更新する。一方、API 名から想起される契約との差は明文化が必要である。 -2 パッケージ分割自体は明快で、下方 interop(`container.appServer` / `CodexStartedReview.session`)も実際に使われている。ただし AppServerKit 側での mutation が DataKit fetch results に反映されない coherence 境界は未文書。 +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 過不足 -### 過剰(存在しない・無意味な API) +### stock upstream と整合しない surface -| API | 問題 | finding | +| surface | 判断 | finding | |---|---|---| -| `account/login/complete` + `nativeWebAuthentication` | upstream 全履歴に不存在。素の codex では native web-auth が無言で never engage。fork 前提なら明示が必要 | `cov-invented-login-complete` | -| `turn/failed`・`turn/cancelled`・`item/updated`・`agent/message` decode | v2 に不存在。public `.turnFailed` は永久に発火しない。`agent/message` が fallback-ID 機構を駆動。CRK preview が `item/updated` に依存し、invented semantics が外に漏れている | `cov-dead-compat-notifications` | -| `CodexTurnStatus.cancelled` | protocol が emit しない独自 case。CRK が分岐している | `cov-invented-turn-status`(low) | -| `CodexReviewSession.steer()` | upstream が review turn への steer をカテゴリカルに拒否(`activeTurnNotSteerable`)— 必ず失敗する public API | `conf-review-steer-always-rejected`(low) | -| `Permissions.profileSelection`(object 形) | upstream は plain string 期待。latent wire break | `conf-permissions-object-shape`(low) | +| `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 | |---|---|---| -| typed error taxonomy + `error.data` 保持 | typed catch 0 件、message 文字列 match、SDK 自身も interrupt 判定を文字列 parse | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded` | -| timeout | ホスト側で手作りタイムアウト。wedged binary で起動永久ハング | `dx-no-timeout-story` | -| outbound raw JSON-RPC escape hatch | 週次で動く upstream の新 method を呼べない(send path は 1 本なので追加は安価) | `dx-no-raw-escape-hatch` | -| server→client request の typed surface + `serverRequest/resolved` | `{}` 応答は upstream 側 serde 失敗 → deny 降格。custom handler は abort 時に永久待ち | `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | -| identity での cancel | registry miss 時にセッション全体を resume してから cancel | `use-resume-to-cancel` | -| review 最終出力の所在の契約 | 3 段 fallback チェーン + 2 層での失敗合成(この領域だけで fix 5+ commits) | `use-review-output-location` | -| 死活監視 surface | `accountEvents` stream の throw を「サーバー死亡」のサイドチャネルとして利用 | `use-runtime-death-side-channel` | -| `deprecationNotice` / `error.willRetry` の typed 化 | thread/rollback 除去シグナルと transient-retry シグナルが不可視 | `cov-error-warning-untyped` | -| thread lifecycle notification の DataKit ingestion | MCP provider が read 毎に全 refresh + silent catch | `dk-query-not-live-external`, `use-mcp-refresh-fallback` | +| 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 系 — 消費者に需要がなく workaround 圧力も観測されず。CRK production code に手書き JSON-RPC はゼロ(raw 文字列は preview/test transport に限定)。`review/start`・ReviewTarget・ReviewDelivery は upstream と正確に一致。v1 deprecated methods は正しく不採用。 +### 適切に無いもの -## 6. 修正方針(優先順位付き) +`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 である。 -### P1 — SDK の契約修正 +## 6. 修正方針 + +### P1 — public contract と lifecycle owner | # | 修正 | 回復する invariant | findings | |---|---|---|---| -| 1 | turn 終端の 3 値化: `isFailure` から interrupted/cancelled を外し、`collect()`/`respond()` は中断時に throw せず返す。progress の `.failed` 報告も分岐 | ユーザー中断は失敗ではない(upstream 契約) | `conf-interrupted-is-failure`, `dx-cancel-semantics-inconsistent` | -| 2 | `thread/closed` = unload: subscriber を終端しない、DataKit が `.completed` を捏造しない | thread の生死は server が所有し unload は状態遷移 | `ask-thread-closed-terminal`, `dk-closed-fabricates-completion` | -| 3 | send boundary 1 箇所で typed error 化(code + CodexErrorInfo + requestID)、CancellationError 透過、per-request timeout。interrupt 判定を `codexErrorInfo` の typed 判別へ | 失敗の分類は SDK が所有する | `dx-error-taxonomy-uncatchable`, `conf-error-payload-discarded`, `ask-cancel-collect-misreported`, `ask-interrupt-error-string-parsing`, `dx-no-timeout-story` | -| 4 | terminal turn 後の router history prune(replay 契約と両立する設計)+ deinit backstop | 接続寿命 = 資源上限 | `ask-router-history-unbounded`, `ask-process-leak-no-deinit` | -| 5 | invented login surface の決着(fork 前提の明示 or 撤去)。`thread/rollback` 依存の脱却計画(まず deprecationNotice の typed 化) | public API は実在する upstream 契約に裏付けられる | `cov-invented-login-complete`, `cov-rollback-deprecated` | +| 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` | -P1-1/2 が入ると、CRK 側 terminal-cascade 3 層(`use-terminal-cascade`)、`.closed→.failed` 変換(`arch-closed-maps-to-failed`)、high-level surface bypass(`use-highlevel-surface-bypass`)の削除条件が立つ。 +`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 — 再発防止の契約整合 +### P2 — DataKit / Testing の再発防止 | # | 修正 | findings | |---|---|---| -| 6 | DataKit mutation 戦略の単一 owner 化 + `load()` の直列化 / generation token | `dk-mutation-strategy-scattering`, `dk-unserialized-fetch-loads` | -| 7 | dead decode 4 種の削除 + `CodexMessageDelta.itemID` required 化(fallback-ID subsystem ごと削除) | `cov-dead-compat-notifications`, `dk-optional-delta-id-workaround-layer` | -| 8 | Testing fake の契約一致: unstubbed fail-fast、thread/list の archived/sort 実装、server-request 注入 API、typed wire emitter | `test-unstubbed-method-silent-success`, `test-store-ignores-archived-and-sort`, `test-no-server-request-injection`, `test-handrolled-notification-schemas-drift` | -| 9 | predicate の throwing validation 化 + archived 暗黙注入の廃止(最低限、両方の文書化) | `dk-predicate-runtime-crash-contract`, `dk-implicit-archived-scope` | -| 10 | server request の typed surface + `serverRequest/resolved` 処理 | `cov-server-requests-untyped`, `conf-server-request-resolved-ignored` | +| 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 — 消費側の整理 +### P3 — CodexReviewKit の整理 | # | 修正 | 条件 | |---|---|---| -| 11 | terminal-cascade / `.closed→.failed` / resume-to-cancel / observe 直列化の縮小 | P1-1,2 と SDK cancel API の後 | -| 12 | **今すぐ削除可能**: item-status 再導出 cascade(SDK `2a4d2f5` が所有済み — 検証で確定)、dead adapter branches、`CodexReviewHost` + `DirectCodexReviewStoreBackend` | 即時 | -| 13 | login teardown の owner 化(9 fields を単一 session 型へ)、`"attempt-1"` fallback 除去、ForTesting forwarder 3 層(~100 accessors)の直接 seam 化 | 即時 | +| 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 後 | -## 7. won't-fix(観測誤りとして反証済み) +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 と同一視して全削除しない。 -- **`test-fake-close-clean-finish-hangs-subscribers`**(fake close() で subscriber がハング): fake の `close()` は package-scoped で消費者から到達不能。public 経路 `CodexAppServer.close()` は `router.stop()` が先に throwing finish するためハングは起きない。`finishNotificationStreams(throwing:)` は public(`CK:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1000`)。残る clean/throwing 終端の非対称は low。 -- **`use-item-status-rederivation`**(SDK が item status を finalize しない): 前提が誤り。CodexKit `2a4d2f5`(2026-07-01)が全 terminal 経路で `itemByApplyingTerminalLifecycleStatus` を適用済み(`CK:Sources/CodexDataKit/CodexModel.swift:1703-1740`)、exitCode ルールも SDK 側に同一実装(:1765-1773)。CRK 側 projection cascade(`CRK:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:557-596`)は live workaround ではなく削除可能な残骸 → P3-12。 +## 7. 反証・今回採用しない提案 -## 8. テストチェックリスト +- **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:)` はそれらを迂回するため、現時点の不足・「安価な追加」とは判定しない。 -- [ ] `turn/completed(status=interrupted)` で `collect()` が throw しない — sibling: `respond()`、`progress`、DataKit `chat.phase` -- [ ] `thread/closed` 後に同 thread を resume でき、subscriber が終端しない -- [ ] 実 wire 形状(`turn/completed` + status=failed + `turn.error`)で `chat.phase == .failed` を pin(fictional `turn/failed` テスト `CK:Tests/CodexDataKitTests/CodexDataKitTests.swift:8091` の置換) -- [ ] fake: unstubbed method で fail-fast -- [ ] fake: `thread/list` が archived・sortKey・sortDirection を尊重 -- [ ] cancel 中の `respond()` が `CancellationError` を投げる -- [ ] 並行 `load()` の順序(A→B→A 完了で items/cursor が一致) -- [ ] `close()` なしの drop で子プロセスが reap される -- [ ] 長寿命接続で router history が prune される -- [ ] `serverRequest/resolved` で custom handler の待機が解放される +## 8. テストチェックリスト -**観測性の欠落**: `error` notification の `willRetry`(唯一の transient-retry シグナル)と `deprecationNotice` が `.unknown` 経由で不可視。typed event 化を P2-10 に含める。 +- [ ] 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. 監査の限界 -- low 36 件(Appendix B)は adversarial 検証を通していない(レートリミット対応で検証対象を high+medium に絞った)。 -- ReviewChatLogUI / ReviewUI の view 層は core state ファイル精読 + view skim に留まる。 +- 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(high + medium、51 件) +## 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 mutable fields reset by hand at 7 call sites in LiveCodexReviewStoreBackend +### `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 torn down by manually nil-ing/cancelling each field in 7 places with subtly different orderings and subsets (cancelAuthentication, startLogin catch, monitorAuthenticationSession catch, handleAuthenticationSessionCancelled, handleLoginCompletedNotification failure, finishCompletedLoginAfterAccountUpdate, takeLoginRuntimeForCleanup). Any new exit path must replicate the full reset or leak an isolated app-server process/temp CODEX_HOME. The strongest owner-absent signal in the repo; the 2127-line class also owns app-server lifecycle, MCP HTTP server, rate-limit refresh, and account-registry persistence. +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 copies :670-702, :915-940, :974-991, :1033-1062, :1300-1321, :1338-1384, :1559-1578 — same invariant, different order/subset. -- 検証時の訂正: Field count is 10, not 9 (finding omitted authNotificationTask :226, which is app-server-scoped rather than login-scoped, so 9 login-flow fields is defensible). Reset-site count: 7 clusters as claimed, plus an 8th partial subset reset inside startLogin at :885-888 the finding missed — strengthening, not weakening, the claim. +- 証拠: 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` — Cancelling a task awaiting respond()/collect() surfaces transportClosed instead of CancellationError +### `ask-cancel-collect-misreported` — Task cancellation during event iteration can surface transportClosed instead of CancellationError **CONFIRMED / high / bug** — `CodexKit:Sources/CodexAppServerKit/CodexTurnSequences.swift:490` -On task cancellation AsyncThrowingStream returns nil (does not throw), so collect()'s for-loop exits without a terminal event and throws CodexAppServerError.transportClosed — misclassifying every user cancellation as a transport failure for respond(to:), CodexResponseStream.collect(), and waitForCancelledResponse (CodexDomainTypes.swift:2111). Consumer already works around it: AppServerCodexReviewBackend checks Task.isCancelled explicitly (:753) and its `catch is CancellationError` branch (:759) is unreachable. +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() (CodexDomainTypes.swift:1994-2008) awaits turn.result() → CodexResponseCollector.collect iterating turn.events (CodexTurnSequences.swift:468). 3) Consumer cancels the awaiting Task; onCancel fires the detached `Task { try? await turn.interrupt() }` (:2002-2007); the AsyncThrowingStream iterator returns nil per cancellation semantics. 4) The for-loop exits without .completed/.failed → `throw CodexAppServerError.transportClosed` (CodexTurnSequences.swift:490). 5) collect()'s catch runs handleFailure() then rethrows (:1999-2000) — caller receives .transportClosed for a user-initiated cancel; same shape for waitForCancelledResponse (CodexDomainTypes.swift:2111). -- 修正の方向: collect()/result() own the terminal contract: after loop exit without terminal, check Task.isCancelled and throw CancellationError (or typed .cancelled); reserve transportClosed for real transport termination. +- 失敗トレース: 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); neither the transport nor CodexAppServer has a deinit backstop, and the child runs in its own process group (POSIX_SPAWN_SETPGROUP), so losing the last reference orphans a running `codex app-server` process. Repeated container recreation (login change, tests forgetting close()) accumulates orphans; reaping (reapIfExited) only runs inside terminateAndWait. +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 only termination paths; :662-663 setpgroup; grep deinit over Sources/CodexAppServerKit = zero hits; CodexAppServer.swift:216-223 close() is sole teardown. -- 検証時の訂正: Finding understates it: not only is there no deinit backstop, a router-side retain cycle (routerTask strongly captures the router at CodexAppServerNotificationRouter.swift:89-97 while the router stores it at :19) means the transport can never deallocate without close(), so the Swift objects leak too and any dealloc-based stdin-EOF fallback is unreachable. +- 証拠: 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. -- 修正の方向: Transport owns 'no reachable transport ⇒ no child process': add a deinit that signals the process group (synchronous kill + detached reap) or hold the pid in a terminator token independent of actor lifetime; at minimum log the leaked pid. +- 修正の方向: 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 @@ -210,170 +226,156 @@ turnHistoryByTurnID, threadHistoryByThreadID and threadIDByTurnID are append-onl - 証拠: 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: prune turn history at terminal+no-replay-consumers, and drop thread history behind the current generation cursor (beginThreadEventGeneration already knows the cutoff). +- 修正の方向: 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` — User-interrupted turns are classified as failures across every SDK layer (isFailure includes interrupted/cancelled), so collect()/progress/DataKit all misreport user cancels as errors +### `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, the only turn-terminal event is turn/completed and status=interrupted is a normal non-failure outcome (interrupt → status Interrupted, error None). CodexTurnStatus.isFailure returns true for .interrupted and .cancelled and every terminal path keys off it: CodexResponseCollector/respond() throw turnFailedWithResponse, turn/review progress sequences yield phase .failed, CodexModel.fail(with:"interrupted") marks the whole chat .failed on turnCompleted and again on refresh (CodexModel.swift:1145-1151, 2371-2376), with CodexDataPhase having no non-failure interrupted terminal. waitForCancelledResponse uses a third inconsistent mapping. Consumer proof: zero collect() call sites in CodexReviewKit; the backend re-splits interrupted/cancelled back out of the failure path (AppServerCodexReviewBackend.swift:623-633) and elsewhere guesses via `error is CancellationError`, missing server-side interrupts. Cross-ref use-terminal-cascade, use-highlevel-surface-bypass, cov-invented-turn-status. +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 incl. interrupted/cancelled; CodexTurnSequences.swift:303-309,440-447,477-487; CodexModel.swift:1145-1151,2371-2376; CodexDomainTypes.swift:2088-2112 divergent waitForCancelledResponse; upstream turn.rs:28-36 TurnStatus, bespoke_event_handling.rs:1438-1460 interrupt → Interrupted/error None; consumer AppServerCodexReviewBackend.swift:626-631. -- 検証時の訂正: Citation nit: TurnStatus is turn.rs:29-35 (cited 28-36); DataKit refresh re-fail is CodexModel.swift:2368-2372 (cited 2371-2376). Substance accurate. -- 失敗トレース: (1) User cancels a running turn (CodexResponseStream.cancel() -> turn/interrupt, or any server-side interrupt); (2) upstream emits turn/completed {turn.status: "interrupted", error: null} (bespoke_event_handling.rs:1448-1459); (3) router decodes .completed(CodexResponse status .interrupted) (CodexAppServerNotificationRouter.swift:659-660, :1011-1026; "interrupted" -> .interrupted CodexDomainTypes.swift:1852-1853); (4) CodexResponseCollector.collect / CodexTurn.result: result.status?.isFailure == true because isFailure includes .interrupted (CodexDomainTypes.swift:1878-1885) -> throws CodexAppServerError.turnFailedWithResponse (CodexTurnSequences.swift:482-484) — a normal user cancel surfaces as a thrown turn failure; (5) progress sequences yield phase .failed(.turnFailedWithResponse) (CodexTurnSequences.swift:303-309, :440-447); (6) DataKit marks the whole chat .failed("interrupted") on turnCompleted (CodexModel.swift:1145-1151) and again on every refresh (:2368-2372), with no non-failure interrupted phase available (CodexDataPhase.swift:3-8); (7) consumer must compensate by re-splitting interrupted/cancelled out of the failure path (AppServerCodexReviewBackend.swift terminalFailureEvents ~:626-633). -- 修正の方向: The terminal-outcome type owns the distinction: replace isFailure-driven branching with a three-way outcome (completed | interrupted | failed(TurnError)) on CodexResponse/progress phases; collect() returns the response for any terminal status, throwing only for failed/errorMessage; DataKit lands interruption as loaded/idle with turn.status == .interrupted. +- 証拠: 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. Against a stock server: the extra field is silently ignored, the response never echoes nativeWebAuthentication, so the consumer's gate `completesLoginThroughCallback: nativeCallbackScheme != nil` is always false and the ASWebAuthenticationSession callback-completion feature silently never engages; completeLogin would fail method-not-found. Consumer ships real wiring and CodexKit's README documents it as standard API with no fork requirement noted. If a patched codex build is intentionally targeted, that dependency is undocumented and unpinned. +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 field; CodexAppServer.swift:799-812,855-862; upstream common.rs:1001-1013 no login/complete, account.rs:68-86,124-137 no nativeWebAuthentication; git log --all -S in /Users/kn/Dev/codex returns nothing; consumer LiveCodexReviewStoreBackend.swift:911,961; README.md:418-421. -- 検証時の訂正: Citation fix: the README documenting the flow is CodexKit Sources/CodexAppServerKit/README.md:414-421, not the top-level README.md:418-421 (top-level README has no login content, 94 lines). Method line is AppServerRequests.swift:2285 as cited. -- 失敗トレース: 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, no deny_unknown_fields; (3) response is {type:"chatgpt", loginId, authUrl} only (account.rs:~125-137), so decodeIfPresent(nativeWebAuthentication) yields nil (AppServerRequests.swift:2207-2210) and CodexChatGPTLogin.nativeWebAuthentication == nil; (4) consumer gate completesLoginThroughCallback: nativeCallbackScheme != nil is always false (LiveCodexReviewStoreBackend.swift:911), so the ASWebAuthenticationSession callback-completion feature never engages, silently; (5) any caller that follows the README's documented step 3 and calls completeLogin sends method account/login/complete (AppServerRequests.swift:2285) which no codex revision has ever implemented -> JSON-RPC method-not-found at runtime. +- 証拠: 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 fails at runtime with method-not-found mid-recovery, after the turn was already interrupted. Compounding: deprecationNotice notifications are surfaced only as .unknown (cross-ref cov-error-warning-untyped), so the removal warning is invisible. +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. -- 修正の方向: Decide what restart/revert mean without rollback (upstream direction is fork/resume-based history editing), isolate the dependency behind one internal seam, and surface deprecationNotice as a typed event so consumers get the removal signal. +- 修正の方向: 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` — Public API throws package/private error types; CodexAppServerError taxonomy is mostly dead — consumers cannot catch anything by type +### `dx-error-taxonomy-uncatchable` — Request/transport/decode failures often escape the public error taxonomy **CONFIRMED / high / api-design** — `CodexKit:Sources/CodexAppServerKit/JSONRPC.swift:33` -Every request rethrows package-scoped JSONRPC.Error unmapped (uncatchable by type); the most common first-run failure (codex not installed) throws private AppServerProcessTransportError; undecodable responses throw raw DecodingError. CodexAppServerError's serverBusy/retryLimitExceeded/malformedNotification have zero throw sites, .jsonRPC is thrown only for client-side login-URL validation with a fabricated -32602, and after overload retries the raw JSONRPC.Error is rethrown instead of .retryLimitExceeded. router.stop() also finishes all event streams with JSONRPC.Error.closed even on graceful close(), so long-lived for-await loops throw an untypeable error on normal shutdown. Consumer confirms: zero typed catches in CodexReviewKit, 30+ localizedDescription reductions — binary-missing, server-rejection and SDK decode bugs render identically. +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 rethrows unmapped; AppServerProcessTransport.swift:873 private error enum; CodexDomainTypes.swift:2839-2841 dead cases (grep: declaration-only); CodexAppServer.swift:1105-1133 only .jsonRPC sites; router stop CodexAppServerNotificationRouter.swift:168-172; consumer LiveCodexReviewStoreBackend.swift:511 et al. -- 検証時の訂正: Consumer localizedDescription count is 37 (finding said 30+ — consistent). -- 修正の方向: AppServerClient owns the transport→domain error boundary: map JSONRPC/transport/decode failures into public CodexAppServerError cases (spawnFailed, serverError(code:message:), invalidResponse, transportClosed) at the single send boundary, delete or wire the dead cases, and finish streams without error on graceful close. +- 証拠: 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 deadline/timeout anywhere: init handshake, every request, and collect() can hang forever with no dedicated timeout error +### `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` -No timeout mechanism exists in CodexAppServerKit (grep-verified; the only Duration uses are shutdown grace and turn metadata). CodexAppServer.init awaits the initialize handshake with no deadline (wedged binary hangs app startup forever); every request parks a CheckedContinuation resolved only by response or transport close; collect()/turn streams finish only on a terminal turn event or close — the router finishes thread subscribers on thread/closed but not turn subscribers, so one missing turn/completed wedges collect() permanently. No TimeoutError type exists. The consumer compensates with hand-rolled timeout scaffolding (observation awaiter, worker drain, test wrappers). +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 no deadline; AppServerProcessTransport.swift:113-131 parked continuation; router :308-310 vs :333-335 asymmetry; CodexDomainTypes.swift:404-441 no deadline field; consumer ReviewObservationAwaiter.swift:40-56, CodexReviewStoreCancellation.swift:243-254, CodexReviewStoreReviews.swift:34-59. -- 修正の方向: AppServerClient owns per-request deadlines (optional Duration raced via structured concurrency, dedicated public timeout error distinct from transportClosed); Configuration owns the handshake deadline; streaming turns get an inter-event-gap deadline. +- 証拠: 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 with every thread stamped with the query's archived flag (fabricated scoping), and list order is store insertion order rather than the recency/created_at descending order DataKit explicitly trusts. Consumer previews already hand-stub thread/archive and track archived IDs themselves — the workaround signal that the store's mutation/scope owner is missing. +`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:706-754 (no archived/sortKey/sortDirection reference); Params carry them AppServerRequests.swift Thread.List.Params; DataKit pins them CodexAppServerKitTests.swift:592-618; archived stamped CodexAppServer.swift:657; upstream thread.rs:1077-1094; consumer workaround ReviewMonitorPreviewAppServerRuntime.swift:335-341. -- 検証時の訂正: The citation 'archived stamped CodexAppServer.swift:657' is imprecise: :657 only serializes query.archived into the request; the stamping owner is DataKit's CodexFetchRequest.swift:1134-1139, which sets isArchived from the query scope on every returned chat. List order is init-order with upsert-moved-to-front (recency-of-mutation), not strict insertion order, but it still ignores sortKey/sortDirection and the upstream created_at-descending default. Upstream cite is protocol/v2/thread.rs. +- 証拠: 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 dedups review output and reasoning mirrors by normalized-text equality and re-parses rawPayload JSON to absorb unstable item identity +### `use-item-identity-text-dedup` — UI uses normalized text and raw payload kind to choose a presentation among distinct transcript items -**CONFIRMED / high / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:624` +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift:624` -One logical entry reaches the consumer as multiple items with different IDs (review output as exitedReviewMode item AND assistant message; one reasoning entry under two payload kinds: agent_reasoning event mirror + reasoning item). The projection maintains three text-equality compensation mechanisms — ReviewOutputKey turn-scoped dedup, the PendingReasoningMirrors pairing state machine keyed by scopeID+trimmed text, and RawPayloadKind JSON-decoding of item.rawPayload because the typed API exposes no payload-kind discriminator. Recurrence: 7 of 11 commits touching the file fix this dedup/status area (45b64f1, 537a9e4, 4c79ed5...). The SDK independently does its own text-signature fallback resolution (CodexFallbackAgentMessageSignature) — the same identity invariant implemented twice at two layers with no owner (cross-ref dk-optional-delta-id-workaround-layer). +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. -- 修正の方向: CodexDataKit owns canonical item identity: one persisted item per logical entry with stable itemID and a typed payloadKind discriminator; consumer text-matching and raw-JSON parsing then die. +- 修正の方向: 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` — Three-layer terminal-status cascade in the consumer compensates for the SDK's missing cancelled-terminal contract +### `use-terminal-cascade` — SDK outcome adaptation and product cancellation arbitration are conflated as one workaround cluster -**CONFIRMED / high / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:812` +**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:812` -Because CodexKit has no first-class cancelled terminal (interrupted classified as failure; cross-ref conf-interrupted-is-failure), the consumer rewrites terminal status at three layers: backend adapter re-derives .cancelled from interrupted/cancelled status; ReviewBackendEventSession emits a synthetic .cancelled on finish and ignores all later events via a finished flag (metrics.ignored instruments expected duplicate/late terminals); the store's completePendingCancellationIfNeeded is called at 7 sites and rewrites ANY backend terminal to cancelled while cancellationRequested is set. Expected: one typed terminal per turn from the SDK. +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:812 (also 685,702,706,729,735,843); ReviewBackendEventSession.swift:124-127 finished guard, :144-146 synthetic .cancelled; AppServerCodexReviewBackend.swift:627-633; SDK CodexDomainTypes.swift:1878-1882, router :661,:748. -- 検証時の訂正: completePendingCancellationIfNeeded has 6 call sites (CodexReviewStoreReviews.swift:685,702,706,729,735,812), not 7; 7 counts the definition at :843. Everything else accurate. -- 修正の方向: CodexAppServerKit owns terminal typing: review surfaces deliver exactly one typed terminal (completed|cancelled|failed) per turn, deriving cancelled from turn/completed+interrupted; consumer layers collapse to record-keeping. +- 証拠: 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` — attemptID identity is fabricated by "attempt-1" fallbacks and unknown attempts silently get a fresh registered event session that clobbers thread registries +### `arch-attempt-id-fallback` — "attempt-1" defaults create a latent second source of attempt identity -**CONFIRMED / medium / bug** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:349` +**CONFIRMED / low / api-design** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:349` -Attempt identity gates event consumption and session lookup, yet is defaulted in three layers (Run.init default, decode fallback, backendRun reconstruction all "attempt-1"). When cancelReview takes the backendRun fallback branch and the backend has no session for that attemptID, reviewEventSession(for:) silently creates AND registers a new session, overwriting activeReviewAttemptIDByThreadID and the canonical-threadID map for all associated threads — a get-or-create mutation on what callers treat as a lookup. Same fallback-identity pattern the prior CodexKit audit flagged, one layer up. +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-358 get-or-create+register, :360-382 registry overwrite; CodexReviewTypes.swift:239,254 fallbacks; CodexReviewStoreReviews.swift:906 reconstruction fallback, :431-450 caller branch, :1027-1029 attemptID gate. -- 検証時の訂正: The registry-clobber harm is narrower than the wording implies: reaching the fallback branch with a live competing attempt on the same threadID requires a persisted record whose attemptID is nil while another attempt reuses that threadID (thread reuse exists — restartPreparedReview keeps interruptedRun.threadID :247 — but restarts persist real attemptIDs via applyBackendRun :237, so the collision needs legacy data). The unconditional facts stand: a cancel of a stale record fabricates identity and mutates registries via what every caller treats as a lookup. -- 失敗トレース: 1) Run record persisted with threadID set but attemptID nil (legacy schema; ReviewRunCore.swift:5 Optional, CodexReviewTypes.swift:254 decode fallback). 2) After relaunch, runtimeState has no activeRun, so cancelReview takes the backendRun branch (CodexReviewStoreReviews.swift:431) with fabricated attemptID "attempt-1" (:906). 3) backend.interruptReview → AppServerCodexReviewBackend.swift:187 reviewEventSession(for:) → no session for "attempt-1" → new AppServerReviewEventSession created and registered (:355-356). 4) registerReviewEventSession overwrites activeReviewAttemptIDByThreadID[threadID]="attempt-1" and canonical map for all associated threads (:373-380); any later thread-keyed lookup (finishReviewEventStream :447, metrics :324) resolves to the fabricated session instead of a real one. 5) The new session has no reviewSession, so cancelReviewTurn's session path returns nil (:736-738) and the code silently resume-and-cancels via appServer (:466-470) — a phantom session remains registered for a nonexistent attempt. -- 修正の方向: Backend owns attempt identity: interrupt/cleanup for an unknown attemptID is a no-op-with-log or explicit error, never a fabricated registered session; drop the "attempt-1" fallbacks and make attemptID non-optional at the store record so missing identity fails fast. +- 証拠: 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` — Consumer maps CodexReviewEvent.closed (upstream idle-unload, resumable) to review failure +### `arch-closed-maps-to-failed` — Adapter contains a latent `.closed` / `.notLoaded` to failure mapping -**CONFIRMED / medium / protocol-mismatch** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:584` +**CONFIRMED / low / protocol-mismatch** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:584` -AppServerTypedReviewEventAdapter maps .closed → [.failed("Review thread closed.")] and statusChanged(.notLoaded) → failed, turning an unload notification into a terminal user-visible failure. The SDK forwards .closed with no semantics and its progress sequence silently ends on it (cross-ref ask-thread-closed-terminal), so the consumer invents semantics. Harmless today only because the finished-flag terminal-cascade guard ignores post-terminal events; if .closed ever precedes the terminal (e.g. deferred deleteThread ordering changes), a successful review is recorded failed. The backend already has restart/resume machinery (prepareReviewRestart/resumeReview) that .closed bypasses. +`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:583-584, :574-575; restart machinery :201-257,466-470; upstream thread_lifecycle.rs:397-436; SDK pass-through CodexDomainTypes.swift:720,749-750; CodexTurnSequences.swift:329-331. -- 検証時の訂正: Minor: the post-terminal protection is BackendReviewEventMailbox.terminal (append guard in CodexReviewBackend.swift), not the ReviewBackendEventSession `finished` flag — that flag (ReviewBackendEventSession.swift:44,124) is only set by finish()/abandon(), not on terminal emit (receive() at :133-137 returns without setting finished). The protective effect is as described. Also, upstream ordering makes the latent bug hard to hit: unload requires the thread to be idle (turn already completed), so turn/completed reaches the mailbox first in the observed server code. -- 失敗トレース: Latent path (guarded today): 1) review turn completes → adapter emits .completed (AppServerCodexReviewBackend.swift:553-554,590-607) → mailbox.terminal=.finished (CodexReviewBackend.swift append). 2) app-server later unloads the idle review thread → thread/closed (thread_lifecycle.rs:429-434) → SDK forwards .closed (CodexDomainTypes.swift:749-750) → adapter converts to .failed("Review thread closed.") (AppServerCodexReviewBackend.swift:583-584) → dropped only by the mailbox terminal guard. If .closed ever reaches the mailbox before the terminal (guard removed, or ordering change), the completed review is recorded failed. The mapping itself (unload → user-visible failure) is the confirmed protocol mismatch. -- 修正の方向: Adapter classifies .closed as an interruption eligible for the existing restart path (or a distinct terminal reason), not a failure — ultimately fixed by the SDK modeling .closed as non-terminal unload. +- 証拠: 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 + DirectCodexReviewStoreBackend are production code with only test callers, duplicating Live backend adaptation logic +### `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 class and test the store against a CodexReviewTesting fake of CodexReviewBackend, or promote it to the real headless composition root sharing mapping helpers with the Live backend — one owner for backend→store adaptation. +- 修正の方向: 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` — ~100 ForTesting accessors mechanically forwarded through three layers (scroll view → chat-log target → transport VC) +### `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 devotes ~510 of its 861 lines to a DEBUG extension forwarding every test probe to logScrollView; ReviewMonitorTransportViewController repeats the list one layer up (~570 lines) prefixed with `log`. Every new leaf capability requires three mechanical wrapper edits — tests lack a direct seam to the leaf render surface, and the noise hides the two types' real contract (bind/clear/render). +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 (:373-379 example); ReviewMonitorTransportViewController.swift:205-771 (:247-257 same accessors re-wrapped). -- 検証時の訂正: Accessor count is slightly above the finding's "~100": 107 at the target layer and 124 at the VC layer (the VC adds a few of its own probes, e.g. placeholder state :235-245, that are not forwards). Line spans as cited. +- 証拠: 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 the active turn by splitting the server error message on " but found " (activeTurnID) and retries on lowercase substrings "no active turn"+"interrupt", sending turnId:"" as a deliberate mismatch probe. These match today's exact format! strings (which already differ between upstream call sites) and are not a contract — any rewording silently breaks cancel. Upstream returns the same 'no active turn' error for both not-yet-activated and already-terminal turns, so cancelling a just-finished turn burns ~250ms of retries then throws an untypeable JSONRPC.Error instead of being treated as already-cancelled. The post-loop `throw CancellationError()` (:496) is unreachable. Root cause is an upstream affordance gap (no structured code / active-turn query); cross-ref conf-error-payload-discarded. +`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 retry loop, activeTurnID parse, isExpectedTurnNotActive; upstream /Users/kn/Dev/codex/codex-rs/app-server/src/request_processors/turn_processor.rs:904,1364-1373 differing message shapes and :1371 terminal-turn same-error path. -- 検証時の訂正: One evidence claim is wrong: `turnId:""` is not a 'deliberate mismatch probe'. Upstream treats empty turn_id as a startup interrupt (turn_processor.rs:1352,:1358,:1389) that bypasses the active-turn check and interrupts whatever is running, succeeding without error; CodexKit only sends "" when turnID is nil (CodexThreadOperations.swift:507-510, e.g. cancelActiveTurn(expectedTurnID: nil) :233-243). The ' but found ' discovery fires only when a non-empty stale turnID is sent. Core finding (string coupling, wasted retry on terminal turns, unreachable :496) stands. +- 証拠: 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. -- 修正の方向: Consult router history (hasTerminalTurnEvent) before retrying so locally-known-terminal turns are idempotent successful cancels; resolve the active turn via thread/read instead of the error probe; push upstream for structured error codes, and key retry/redirect on typed error data once error.data is carried through. +- 修正の方向: 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` — thread/closed (idle unload, resumable) is treated as the terminal thread event, permanently finishing subscribers and ending review sequences without a result +### `ask-thread-closed-terminal` — Router ends the current thread generation on unload without exposing a typed end reason -**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:333` +**CONFIRMED / low / api-design** — `CodexKit:Sources/CodexAppServerKit/CodexAppServerNotificationRouter.swift:333` -Upstream emits thread/closed when an idle, subscriber-less thread is unloaded from memory — resumable, not terminal. The router finishes all thread subscribers on .closed and isTerminalThreadEvent is true only for .closed; new subscribers finish immediately while .closed is in the current generation. CodexReviewProgressSequence returns nil on .closed without ever yielding a terminal phase, CodexReviewEventSequence reports .closed as terminal, and CodexResponseCollector throws .transportClosed when a stream ends without turn/completed even though the transport is fine. Consumers are forced to invent semantics (see arch-closed-maps-to-failed) and passive thread.events observers see their loop end on server GC of an idle thread. +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 finishThreadSubscribers on .closed, :522-527 isTerminalThreadEvent; CodexTurnSequences.swift:155-157,:238-239,:329-331,:344-345,:490; upstream /Users/kn/Dev/codex/codex-rs/app-server/src/request_processors/thread_lifecycle.rs:397-435 idle-unload emits ThreadClosedNotification{thread_id} only. -- 失敗トレース: 1) Consumer holds a passive `for await event in thread.events` observer (CodexThreadOperations.swift:9-27) on a thread that goes idle with no server-side subscribers. 2) Upstream unloads it and emits thread/closed (thread_lifecycle.rs:397-435) — thread still resumable via thread/resume (common.rs:488). 3) Router decodes .closed (CodexAppServerNotificationRouter.swift:822-823) and permanently finishes every thread subscriber (:333-335). 4) Any new subscriber for that thread finishes immediately because .closed sits in the current generation (:378-381,:388-392,:522-527). 5) A CodexReviewProgressSequence consumer's loop ends with no terminal phase ever yielded (CodexTurnSequences.swift:329-331), so the review appears to vanish rather than remain resumable — SDK invents terminal-ness the protocol does not promise. -- 修正の方向: Router owns thread-stream lifetime: model .closed as a non-terminal 'unloaded' state (subscribers stay open or finish with a distinct reason), give 'ended without terminal event' its own error instead of transportClosed, and keep terminal-ness tied to connection close and explicit archive/delete. +- 証拠: 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` — commandExecution/fileChange output deltas replace the transcript item, wiping command text and prior output during streaming +### `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` -itemUpdate builds a CodexThreadItem with content .command(.init(command: "", output: )) and CodexTranscriptAccumulator.upsert does items[index] = item, wholesale-replacing the item/started item (which carried the real command/cwd) — transcript consumers see the command string vanish and output show only the LAST delta chunk instead of the concatenation upstream guarantees. item/completed later restores the item, so corruption is transient but visible for the whole command runtime. CodexDataKit independently compensates with accumulatesOutputDeltas merge logic — the recurrence signal that the merge invariant has no owner in the shared accumulator. Cross-ref ask-delta-random-item-identity (identity half of the same function). +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:887-905; CodexTurnSequences.swift:667-668 replace-on-upsert; upstream concatenation contract app-server README:1399, item.rs:1400-1408; compensator CodexModel.swift:1193-1197. -- 検証時の訂正: Citation nit: upstream README's explicit 'concatenate delta values' sentence at :1399 is in the agentMessage section; for commandExecution the chunk-append semantics follow from README:697-705 (zero or more outputDelta chunks for the same item id) rather than an explicit concatenation sentence. Does not change the conclusion: showing only the last chunk and erasing command/cwd is wrong under either reading. +- 証拠: 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 item-merge semantics: typed partial updates (append output, never replace command/cwd/status) in one place consumed by both the accumulator and DataKit, deleting per-layer compensators. +- 修正の方向: 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` — CodexErrorInfo and JSON-RPC error.data are dropped everywhere, forcing string-matching on error messages +### `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 = {message, codexErrorInfo?, additionalDetails?} with a rich enum (contextWindowExceeded, usageLimitExceeded, activeTurnNotSteerable{turnKind}, ...), and steer/interrupt failures serialize the full TurnError into JSON-RPC error.data. CodexKit decodes only {message} and the transport discards error.data entirely. Consequences: consumers cannot distinguish error classes without parsing prose; CodexKit itself must string-match upstream error text for interrupt retry/redirect (cross-ref ask-interrupt-error-string-parsing); `error` notifications' structured payload is invisible (cross-ref cov-error-warning-untyped). +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 never touched; upstream thread_data.rs:266-275 TurnError, shared.rs:63-111 CodexErrorInfo, turn_processor.rs:910-941 TurnError in error.data. -- 検証時の訂正: Citation nits: CodexErrorInfo enum body is shared.rs:~92-133 (cited 63-111 — that range covers the doc comment/TurnError vicinity); TurnError-into-error.data is turn_processor.rs:921-951 (cited 910-941, same code block); the file is app-server/src/request_processors/turn_processor.rs. Substance accurate. -- 修正の方向: Transport carries error.data through JSONRPC.Error; decode TurnError/CodexErrorInfo once (with .other catch-all) so flow control and consumer UX key off typed codes. +- 証拠: 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 @@ -381,9 +383,8 @@ Upstream TurnError = {message, codexErrorInfo?, additionalDetails?} with a rich 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 partial decode; upstream account.rs:505-515 sparse-merge doc, :289-295 dropped fields. -- 検証時の訂正: Citation nits: the sparse-merge doc is account.rs:495-506 (cited 505-515); of the dropped fields, rateLimitResetCredits is at account.rs:~294 in GetAccountRateLimitsResponse while limitName/credits/individualLimit (plus rateLimitReachedType, not listed in the finding) are RateLimitSnapshot fields at account.rs:516-529 (finding cited :289-295 for all). Substance accurate. -- 失敗トレース: (1) Client reads full snapshot: primary+secondary windows and planType populated; (2) server later emits account/rateLimits/updated carrying only {limitId: "codex", primary: {...}} — legal per the sparse-update contract (account.rs:495-506, all RateLimitSnapshot fields Optional :516-529); (3) CodexAppServer.accountEvent decodes it and constructs a brand-new CodexRateLimits from only that payload (CodexAppServer.swift:1166-1182); (4) a consumer treating .rateLimitsUpdated as current state (the natural reading of an event named 'updated' with no partial-delta typing) now observes secondary == nil and planType == nil — previously observed values read as cleared, violating the upstream 'does not clear a previously observed value' contract; no SDK API exposes the previous snapshot to merge against. +- 証拠: 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 @@ -392,50 +393,45 @@ Upstream documents the notification as a sparse rolling update ('merge available 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 decodeThreadEvent default → .unknown (CodexAppServerNotificationRouter.swift:824-826); unmanaged handler tasks AppServerProcessTransport.swift:234-245,289-313; upstream v2/notification.rs:50-56 ServerRequestResolvedNotification, abort sites bespoke_event_handling.rs:154,184,1048. -- 検証時の訂正: Citation nit: ServerRequestResolvedNotification is at v2/notification.rs:52-57 (finding cited :50-56); abort call sites are :154, :184, :1047-1049. Substance accurate. +- 証拠: 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 decodes four notifications that do not exist upstream (turn/failed, turn/cancelled, item/updated, agent/message); dead paths drive live machinery and consumer fixtures +### `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` -None of these methods exist in upstream v2 ServerNotification (common.rs:1613-1710; grep: 0 hits). Consequences: (1) public .turnFailed can never fire — real failures arrive only as turn/completed status=failed, so consumers switching on .turnFailed see nothing, and generation-boundary logic carries dead .turnFailed branches (:567-570); (2) the agent/message path is the sole producer of the fallback-ID system (CodexAgentMessageFallbackID, upsert(replacingFallbackID:), CodexModel text-signature dedup) — dead protocol surface driving live complexity; (3) consumer previews are built on item/updated (ReviewMonitorPreviewAppServerRuntime.swift:563) and deprecated item/fileChange/outputDelta (:632), and SDK tests feed agent/message and turn/failed fixtures, so fakes exercise flows no real server produces. Cross-ref: test-fictional-turn-failed-pins-phase-contract, dk-optional-delta-id-workaround-layer, conf-interrupted-is-failure. +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 :661-662,:668,:698-706,:748-751,:758-762,:788-796 dead method cases; upstream /Users/kn/Dev/codex/codex-rs/app-server-protocol/src/protocol/common.rs:1613-1717 has only TurnStarted/TurnCompleted for turn lifecycle; grep for the four quoted method strings in app-server-protocol/src returns nothing; fallback machinery CodexDomainTypes.swift:2234-2249, CodexTurnSequences.swift:647-693; fixtures CodexAppServerKitTests.swift:3541,3575,3662, CodexDataKitTests.swift:8091. -- 検証時の訂正: One sub-claim is imprecise: agent/message is not the SOLE producer of the fallback-ID system. Fallback IDs are also minted by item/agentMessage/delta events whose itemID is absent (router AgentMessageDeltaPayload.itemID optional, CodexAppServerNotificationRouter.swift:1084-1096; CodexTurnSequences.swift:675-689 append(delta,fallbackItemID:)). agent/message is the sole producer of .message events that drive upsert(replacingFallbackID:) resolution. The thrust survives because upstream requires item_id on agent-message deltas (item.rs:1333-1338 AgentMessageDeltaNotification.item_id: String, non-optional), so the delta-side fallback path is equally dead against a real v2 server. Also minor: dead .turnFailed generation-boundary branches are at router :485, :549, :574-575 (finding cited :567-570). -- 失敗トレース: Consumer switches on public CodexThreadEvent.turnFailed (e.g. CodexReviewKit:Sources/CodexReviewAppServer expects failure events): (1) real server fails a turn -> emits only turn/completed with turn.status=failed (upstream common.rs:1636 TurnCompleted is the only turn-terminal notification; TurnStatus turn.rs:29-35); (2) CodexKit router decodes it as .turnCompleted(response) (CodexAppServerNotificationRouter.swift:746-747), never .turnFailed (:748-752 requires method turn/failed|turn/cancelled which no server sends, grep 0 in app-server-protocol/src); (3) the consumer's .turnFailed branch never executes; failure must be re-derived from response.status/errorMessage. Meanwhile SDK tests and previews pass because their fakes emit the nonexistent methods (CodexAppServerKitTests.swift:3541; ReviewMonitorPreviewAppServerRuntime.swift:562), so fakes diverge from any real server. -- 修正の方向: Router owns the wire-method surface: delete nonexistent-method branches (or synthesize .turnFailed from turn/completed+failed so the typed event means what it says), make delta itemId required, delete the fallback-ID machinery they feed, and migrate preview/test fixtures to real v2 notifications. +- 証拠: 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 has a bespoke routing list for exactly these four methods, but decodeThreadEvent/decodeTurnEvent have no cases for them, so they surface only as .unknown — consumers drop them (AppServerCodexReviewBackend unknownEvents returns []), making 'model retrying' (ErrorNotification.will_retry, the only transient-retry signal) and deprecation warnings (incl. thread/rollback removal) invisible; willRetry is not decoded anywhere. Since v2 ErrorNotification always carries threadId/turnId, the unscoped broadcast branch (:274-286) is unreachable. Related leniency: turnResult() decodes turn/completed with try? — an undecodable payload yields a CodexResponse with empty turnID/nil status that CodexResponseCollector treats as success; TurnError.codex_error_info/additional_details are dropped (cross-ref conf-error-payload-discarded). +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 :556-563 isUnscopedDiagnosticNotification, :274-286 broadcast, decode switches :657-731,:743-827 no cases → .unknown; :1011-1026 turnResult try?; upstream notification.rs:41-48 ErrorNotification, thread_data.rs:270-276 TurnError; consumer AppServerCodexReviewBackend.swift:585-586,645-650. -- 検証時の訂正: One sub-claim is overbroad: the unscoped broadcast branch (:274-286) is unreachable only for the "error" method (ErrorNotification.thread_id/turn_id required, notification.rs:41-48). It IS reachable and load-bearing for warning (thread_id: Option, notification.rs:21-26), deprecationNotice (no thread_id field at all, notification.rs:11-16), and configWarning. The rest of the finding stands as stated. +- 証拠: 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 only the two commandExecution/fileChange approvals; every other server request gets emptyResult() = {}. Upstream requires `answers` on ToolRequestUserInputResponse and `permissions` on PermissionsRequestApprovalResponse — {} fails serde, upstream error!-logs and substitutes empty answers/grant, so every such reply is a logged contract violation degrading to deny. No typed params or decision enums exist even for the approvals the SDK special-cases; item/tool/requestUserInput, item/permissions/requestApproval, mcpServer/elicitation/request have zero CodexKit references, yet Configuration docs claim support for these flows and experimentalApi:true keeps experimental requests in play. Cross-ref conf-server-request-resolved-ignored (stale-request cancellation) and test-no-server-request-injection. +`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` — .closed unload notification terminalizes running turns as .completed, fabricating success in the DataKit model +### `dk-closed-fabricates-completion` — DataKit can synthesize `.completed` from unload/status events in stale-state paths -**CONFIRMED / medium / protocol-mismatch** — `CodexKit:Sources/CodexDataKit/CodexModel.swift:1258` +**CONFIRMED / low / protocol-mismatch** — `CodexKit:Sources/CodexDataKit/CodexModel.swift:1258` -thread/closed says nothing about turn outcomes (unload only, resumable), yet CodexChat.apply(.closed) sets status .notLoaded and force-marks every non-terminal turn and its items .completed with a synthesized completedAt; same fabrication via terminalTurnStatus for statusChanged(.idle/.notLoaded) (:1692-1701). Mid-turn unload records a success that never happened until a later snapshot contradicts it. Cross-ref ask-thread-closed-terminal (router half of the same protocol misreading). +`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 `case .closed: setStatus(.notLoaded); terminalizeActiveTurns(status: .completed, ...)`; :1692-1701 mapping; upstream thread.rs:1467-1469 ThreadClosedNotification{thread_id} only. -- 検証時の訂正: One premise is imprecise: upstream never unloads mid-turn — the unload loop skips while AgentStatus::Running (thread_lifecycle.rs:351-354), so 'mid-turn unload' does not happen server-side. The fabrication is reachable only when the client model still holds a non-terminal turn at .closed/.idle time (missed or out-of-order turn/completed, resumed stale state); the statusChanged(.idle/.notLoaded) mapping at :1692-1701 is the more reachable path. The mismatch itself (assigning a terminal .completed outcome to a pure unload/status notification) is confirmed. -- 失敗トレース: 1) Client model holds turn T with status inProgress (e.g. terminal turn/completed event missed after resubscribe, or resumed thread seeded with a stale running turn). 2) Server unloads the idle thread and broadcasts thread/closed {threadId} (thread_lifecycle.rs:429-434). 3) Router decodes it to .closed (CodexAppServerNotificationRouter.swift:822-823). 4) CodexChat.apply hits case .closed (CodexModel.swift:1258-1264) -> terminalizeActiveTurns(status: .completed) (:1669-1690) sets T.status = .completed and stamps its items completedAt = now (:1703-1733). 5) T's true upstream outcome may be Interrupted or Failed (v2/turn.rs:30-35); the model now records a success the server never declared, until a later snapshot contradicts it. +- 証拠: 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 @@ -451,17 +447,16 @@ CodexThreadServerFilter.init injects archived=false whenever the lowered predica **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 > 0; workspace refresh filters before deciding; paged revalidation and insert offset/limit constraints live elsewhere. Concrete asymmetry: insert skips the usesServerOwnedOrdering check its siblings enforce, so under server-owned ordering a new chat is prepended locally (sortedItems deliberately doesn't sort recencyAt plans) — wrong for .forward order until next refresh. The four recent fixes (c58c47b, b141853, f350209, 33cd29e) all patched this guard area. +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 insert path, :829-851 archive, :887-911 remove, :913-957 workspace, :1116-1132 paged, :1037-1042 canInsertLiveModel, :1066-1076 two guard properties; CodexModelContext.swift:2442-2446 recencyAt skip. -- 検証時の訂正: The commit-history claim is imprecise: c58c47b, 33cd29e, f350209 patched predicate lowering/local-semantics in CodexThreadQueryPlan.swift and b141853 patched sort signatures — i.e. the inputs feeding these guards (isComplete -> membershipRequiresServerRefresh, matches()), not the registration-handler guard lines themselves. Same responsibility neighborhood, different layer. +- 証拠: 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 String?, so DataKit maintains a compensation subsystem: scoped fallback IDs, promotedMessageDeltaKeyByFallbackKey, promoteFallbackMessageDeltaItem, text-equality signature matching (CodexFallbackAgentMessageSignature) in three separate places, plus itemsByReplacingFallbackAgentMessageItems. Text-equality matching is semantically unsound (identical messages in one turn alias) and every new merge path must remember it — the drift class that produced 2bfef6e/25c178a/503ec9a. Cross-ref cov-dead-compat-notifications (the dead agent/message path is the only producer of nil IDs). +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. @@ -470,12 +465,12 @@ Upstream AgentMessageDeltaNotification.item_id is required but CodexMessageDelta **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; everything else hits preconditionFailure in descriptor init, section descriptors, validation, and lowering. CodexQuery.update() computes querySignature (which lowers the predicate) on every SwiftUI update, so e.g. `#Predicate { $0.createdAt > date }` crashes during render. SwiftData throws for unsupported predicates; here 'compiles so it works' is a crash, there is no throwing validation API, and the supported grammar is undocumented (README understates as 'not silently treated as app-server sorts'). Fail-fast was deliberate (269f0f9) but has no discoverable contract. +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 never observe server-side thread-list changes — the SwiftData-style 'live query' expectation does not hold +### `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` @@ -488,27 +483,25 @@ Fetched-results updates fire only from context-initiated actions; CodexModelCont **CONFIRMED / medium / usability** — `CodexKit:Sources/CodexDataKit/CodexModelContext.swift:804` -observe() throws chatObservationAlreadyActive if a registration exists; registration is inserted before the async startObservation completes and cancellation is only possible via the handle returned at the end — so cancel-old-then-observe-new (typical view rebinding) races and throws, since 'cancelled' does not synchronously mean 'slot free'. Updates are already multicast, so the single-slot restriction is an internal lifecycle constraint leaking into public API; no NSFetchedResultsController/SwiftData analogue imposes it. Consumer workaround exists (see use-observe-serialization). +`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-810 throw, :812-827 slot registered before await; consumer CodexReviewKit:Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift:157-166. -- 検証時の訂正: One nuance: 'cancelled does not synchronously mean slot free' is true only for the in-flight-registration case (Task cancellation); cancelling a fully-established CodexChatObservation handle synchronously releases the slot via releaseChatObservation (CodexModelContext.swift:972-981). The consumer workaround targets exactly the in-flight case, so the finding's substance stands. +- 証拠: 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:). If Foundation renames the property or changes the description, it silently returns nil/unstable strings, collapsing comparator distinctions in CodexSortPlanSignature — CodexQuery.update() then reuses the wrong CodexFetchedResults (stale ordering, no error; the failure class b141853 just fixed for key paths). Silent misbehavior contradicts the crash-loudly stance used everywhere else in the file. +`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 Mirror extraction; CodexThreadQueryPlan.swift:174-184 signature includes comparison: String?; commit b141853 precedent. -- 検証時の訂正: Minor precision: String(describing:) of the comparison child is itself layout-dependent even when the label survives; and the collapse today affects only comparator distinctions (path/order are extracted separately), so blast radius is descriptors that differ only in custom comparators. -- 修正の方向: Restrict supported SortDescriptor forms to (keyPath, order) and drop the reflection, or fail fast when the 'comparison' child is absent — signature equality must imply query equivalence without depending on undocumented Foundation layout. +- 証拠: 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/refreshAfterMutation/backfill all funnel into load() with mid-flight awaits and no serialization or generation token: a registration-triggered refresh can interleave with a suspended loadNextPage, giving last-writer-wins items with a nextCursor from the losing load (items/cursor disagreement, A→B→A-completed sequences). Additionally, any included-chat revalidation while paged (nextCursor != nil or offset > 0) reloads page 1 non-appending, discarding pages the user scrolled through — NSFetchedResultsController never resets its window this way. +`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. @@ -518,19 +511,10 @@ performFetch/refresh/loadNextPage/refreshAfterMutation/backfill all funnel into **CONFIRMED / medium / usability** — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2002` -Breaking out of iteration (or dropping the sequence) only unsubscribes — server keeps working — documented nowhere on the type; cancelling the task awaiting collect() fires an unstructured detached `Task { try? await turn.interrupt() }` that DOES stop the work, swallows its failure via try?, and can outlive/race server.close(). Since respond(to:) is streamResponse().collect(), plain respond() silently inherits interrupt-on-cancel while iteration does not — the same type answers 'does cancel stop the work' differently per method. Only the README mentions the collect() behavior. Cross-ref ask-cancel-collect-misreported (error type on the same path). - -- 証拠: CodexDomainTypes.swift:1994-2008 detached interrupt, no doc; CodexThreadOperations.swift:23-25,74; router :105-107,138-140 unsubscribe-only; README.md:102-103. -- 修正の方向: One documented cancellation contract on CodexResponseStream: either task-cancel is pure stop-listening (explicit cancel() to interrupt) or interrupt-on-cancel is structural (withTaskCancellationHandler awaiting the interrupt, surfacing failures) — documented on the type. - -### `dx-no-raw-escape-hatch` — No outbound raw JSON-RPC escape hatch: consumers cannot call any upstream method the SDK has not typed yet +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. -**CONFIRMED / medium / api-design** — `CodexKit:Sources/CodexAppServerKit/README.md:486` - -JSON-RPC and AppServerAPI DTOs are package-internal by design and there is no public (method, params) send. Inbound forward-compat is good (CodexRawNotification), but outbound is fully closed: upstream ships weekly with methods CodexKit doesn't type, so a consumer needing one new call must fork or wait. There is exactly one send path (retry + serial lanes), so a raw send decorating it would be cheap and could not drift. - -- 証拠: README.md:484-487 policy; AppServerClient.swift:6 package actor; CodexAppServer.swift:146-148 package appServerClient; grep public send-like API = none; inbound hatch CodexDomainTypes.swift:2502-2519. -- 修正の方向: CodexAppServer exposes a public sendRaw(method:params:) → Data routed through AppServerClient.send (same retry/lane/error mapping). +- 証拠: 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 @@ -538,8 +522,7 @@ JSON-RPC and AppServerAPI DTOs are package-internal by design and there is no pu 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 emitServerNotificationJSON(method: "turn/failed") + phase assertion; upstream common.rs:1614-1660 TurnCompleted only, turn.rs:30-35; reachable path CodexModel.swift:1142-1151; revert test without phase assertion CodexDataKitTests.swift:6267-6281. -- 検証時の訂正: Severity adjusted high -> medium: this is a regression-risk test gap (removing the dead routes breaks the only failed-phase pin and leaves the real turn/completed+failed shape unpinned), not wrong behavior a consumer hits today. +- 証拠: 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 @@ -548,18 +531,16 @@ The test emits method turn/failed with a top-level error object — a wire event 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:10258-10430 param structs; ReviewMonitorPreviewAppServerRuntime.swift:733-815 and :715-724 status "cancelled"; Testing encoder CodexAppServerTestRuntime.swift:767-842; package scope AppServerRequests.swift:3,162; upstream turn.rs:30-35; invented status CodexDomainTypes.swift:1841,1854. -- 検証時の訂正: Minor: the 'cancelled' status emission is at ReviewMonitorPreviewAppServerRuntime.swift:717-727 (cited :715-724), and the DataKit param structs start at :10204 (cited :10258, which is the first Encodable notification struct). Substance unchanged. -- 修正の方向: Testing target owns typed notification builders (emitTurnCompleted, emitItemUpdated, emitThreadClosed, ...) built on the same package AppServerAPI types the router decodes — single wire-schema owner, delete every mirror. +- 証拠: 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 production API, but JSONRPC.Transport doesn't model server requests (they are internal to AppServerProcessTransport), the fake has zero references to CodexAppServerRequest, and CodexAppServer.testing(transport:) accepts no handler. The only coverage is a shell-script process test at the transport layer; no app developer can deterministically test their approval handler, decision encoding, or UI flow. Cross-ref cov-server-requests-untyped. +`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. -- 検証時の訂正: One premise weakened: no consumer in CodexReviewKit currently uses the approval-handler API at all, so 'no app developer can deterministically test their approval handler' is a prospective gap for the public API, not an observed consumer workaround in this workspace. - 修正の方向: 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 @@ -580,101 +561,98 @@ send falls through to encoding EmptyResponse() when no queued response or handle - 証拠: 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` -A review spans two thread IDs; CodexKit exposes associatedThreadIDs/cleanupThreadIDs but no canonical 'which chat does this review belong to'. The backend keeps three dictionaries to route events/cancellations; the store matches run records by comparing both threadID and reviewThreadID strings; the MCP provider picks reviewThreadID ?? threadID. Each layer re-derives the mapping; drift between them is the bug class the prior generation/identity findings predicted. +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 identity CodexDomainTypes.swift:836-870. -- 検証時の訂正: The claim 'no canonical which-chat mapping' is overstated: CodexReviewIdentity.sourceThreadID/activeTurnThreadID (CodexDomainTypes.swift:639-646) are exactly that accessor pair. The verified gap is narrower — review identity does not flow through the string-typed Run/event plane the consumers operate on, so the three layers re-derive it from raw strings. Also the cited SDK lines 836-870 are CodexReviewSession's mirrors; CodexReviewIdentity itself is at :621-676. -- 修正の方向: CodexReviewIdentity exposes the canonical presentation thread ID and events carry review identity, so consumers key everything off one value. +- 証拠: 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 dedup/projection pipeline on every update, re-deriving diffs via UTF16 text comparison. DX evidence that item identity/ordering in updates isn't trustworthy for targeted apply (consistent with dk-update-id-discontinuity), and a per-delta O(items) cost on hot streaming paths. +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. -- 修正の方向: Once CodexDataKit guarantees stable IDs and ordered updates, apply updates targetedly; until then this is the rational consumer response — fix belongs SDK-side. +- 修正の方向: 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 bypasses every high-level SDK review surface (progress/collect/response) and rebuilds them over raw events +### `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 progress, collect(), messages, logEntries — none used by the app. The adapter iterates raw session.events and re-implements terminal classification, transcript-output extraction, and cancel semantics, because the high-level surfaces classify interrupted as failure and end silently on .closed (cross-ref conf-interrupted-is-failure, ask-thread-closed-terminal). When the SDK's flagship consumer cannot use its convenience layer, that layer's contract is wrong — the strongest DX evidence in the repo. +`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:752-756 raw events only, :590-614 own terminalEvents/reviewCompletionText; avoided surfaces CodexDomainTypes.swift:893-900, CodexTurnSequences.swift:303-309,482-484. -- 修正の方向: Fix the high-level surfaces' semantics (cancelled≠failed, .closed≠silent end) so the convenience layer is adoptable; the adapter then shrinks to a thin mapping. +- 証拠: 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 log provider does a full model refresh per read with silent catch → 'unavailable' fallback +### `use-mcp-refresh-fallback` — MCP refresh failure and projection absence collapse to the same unavailable result -**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift:167` +**CONFIRMED / medium / usability** — `CodexReviewKit:Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift:167` -To serve review_read/await the provider must refresh(chat, includeTurns: true) on every call (poll-style refetch because the model context doesn't reflect the live review thread), swallow refresh errors returning nil, and fall back to an empty ReviewMCPLogProjection.unavailable. Every MCP read pays a full thread fetch; failures degrade silently. Cross-ref dk-query-not-live-external. +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:178-187 refresh + catch { return nil } + guard; fallback :153; ReviewMCPLogProjection.swift:38-51. -- 修正の方向: SDK owns freshness: model context guarantees live review turns are reflected (observation-driven) or exposes a direct transcript-snapshot read by CodexReviewIdentity; the silent fallback becomes an explicit error path. +- 証拠: 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 observe() enforces one observation per chat with fire-and-forget cancel (cross-ref dk-single-observation-slot), the consumer must keep the cancelled task referenced and `await previousTask?.value` before re-observing the same chat, per its own apologetic comment — single-consumer, non-idempotent observation lifecycle pushed onto UI code. +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` — Cancelling a review without a live handle requires resuming the whole session first +### `use-resume-to-cancel` — A registry-miss fallback resumes a review session solely to cancel, but production reachability is unproven -**CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:466` +**PLAUSIBLE / low / usability** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:466` -CodexKit exposes cancel only on live handles; turn/interrupt is package-only. When the in-memory registry misses (post-restart/cleanup), the consumer calls appServer.resumeReview(identity) — constructing a full session with event thread — solely to call .cancel(). The adjacent reviewEventSession(for:) fallback even manufactures an empty session whose cancelReview returns nil, silently routing here. +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. -- 修正の方向: CodexAppServerKit exposes public cancel-by-identity (interruptTurn(threadID:turnID:) or cancel(CodexReviewIdentity)); the resume-to-cancel dance and manufactured-session fallback both die. +- 修正の方向: 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` — 'Review completed without review output' failure synthesized at two layers with a triple fallback chain for where the output lives +### `use-review-output-location` — Consumer duplicates the existing `reviewOutputText` contract with fallback and failure synthesis **CONFIRMED / medium / workaround** — `CodexReviewKit:Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift:610` -CodexResponse gives no contract for where a review's final output lives. The backend probes transcript.reviewOutputText ?? finalAnswer ?? transcript.finalAnswer and synthesizes a failure when empty; the store repeats the same synthesis independently for stream-finished-without-terminal and empty-finalReview; the MCP projection adds a fourth fallback (finalReview ?? lastAssistantMessageText). 5+ fix commits circle this question (8e3ec41, 6e34054, cf78964, e7eac9e, 1ef636f). +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. -- 証拠: AppServerCodexReviewBackend.swift:610-614 fallback chain, :602-604 synthesized failure; same string CodexReviewStoreReviews.swift:688,709,856-863; MCP ReviewMCPLogProjection.swift:91-94. -- 修正の方向: CodexAppServerKit guarantees a single review-output field on the terminal CodexResponse for review turns (finalizedTranscript already merges exitedReviewMode text — finish that ownership). +- 証拠: 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 lifecycle/health surface, so the host keeps an accountEvents() subscription alive partly so its throw becomes the 'server died' signal, then runs full runtime teardown from that catch block. If account notifications were consumed elsewhere or the stream ended benignly, death detection silently changes behavior. +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. -- 修正の方向: CodexAppServerKit exposes a lifecycle surface (state AsyncStream or terminated continuation) so hosts subscribe to death explicitly. - -### `use-subscription-generation-guards` — Store worker builds its own subscription-ID generations to filter stale backend events across restarts - -**PLAUSIBLE / medium / workaround** — `CodexReviewKit:Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift:582` - -Because a superseded attempt's mailbox stream doesn't terminate when a review is restarted after a network outage, the worker maintains monotonic subscription IDs (ReviewWorkerEventSource) and double-guards every input by subscriptionID and attemptID equality. The generation-boundary invariant the prior audit flagged inside CodexKit is reproduced consumer-side: switching generations is again the call-site's cursor dance rather than a producer-owned boundary. - -- 証拠: CodexReviewStoreReviews.swift:582-585,596,609 guards, :1027-1029 shouldConsumeEvent, :1176-1280 generation counter. -- 検証時の訂正: Superseded mailboxes do terminate (abandon()/fail() at CodexReviewBackend.swift:100-115); the actual gap is that supersession is signalled as a plain .finished indistinguishable from normal completion, forcing the worker to pre-emptively unsubscribe and filter by generation instead of reacting to a typed supersede terminal. -- 修正の方向: Backend attempt streams (ultimately SDK review sessions) terminate deterministically when superseded/abandoned so consumers subscribe to exactly one live generation instead of filtering. +- 修正の方向: 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 enteredReviewMode/exitedReviewMode to review-marker: constants and prefixes other kinds with kind.rawValue. Snapshot items bypass the model, so the consumer re-implements the marker constants verbatim — but without the kind-prefix branch, so snapshot-derived and model-derived block IDs diverge for non-marker items. Two unversioned copies of one identity invariant across a repo boundary. +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. -- 検証時の訂正: Severity: the incomplete copy is only exercised by test/preview-style snapshot renders today; no production call site feeds CodexThreadSnapshotLogItem, so the ID divergence is latent rather than user-visible. The two-unversioned-copies maintenance hazard stands. -- 修正の方向: CodexDataKit exposes the semantic/model item ID mapping publicly (or model-normalized projections of CodexThreadSnapshot), making the consumer copy deletable. +- 修正の方向: 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(36 件) +## 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. @@ -704,18 +682,18 @@ CodexDataKit's private semanticID maps enteredReviewMode/exitedReviewMode to rev - 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` [protocol-mismatch] CodexModel decode sorts service tiers lexicographically and merges the deprecated additionalSpeedTiers field — `CodexKit:Sources/CodexAppServerKit/CodexDomainTypes.swift:2695` - - supportedServiceTiers = Array(Set(additionalSpeedTiers + serviceTiers ids)).sorted() — keeps consuming the deprecated field and destroys server-provided ordering by lexicographic sort (the mistake CodexKit correctly avoids for supportedReasoningEfforts). ModelServiceTier name/description are dropped, so UIs can only show raw ids. +- `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's Permissions enum can also encode {type:"profile", id} via .profileSelection (reachable through package CodexThreadPermissions.profileSelection), which serde would reject as -32602. The public .profile(id:) path conforms; the object arm is a latent wire break with no public producer. + - 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 capabilities knob. Every consumer is silently opted into upstream's unstable experimental surface (the flag also changes which server→client requests may arrive). Public stable-looking CodexThread.listTurns() is backed by experimental thread/turns/list — it works only because of the hardcoded flag and can break without semver signal. optOutNotificationMethods is likewise unrepresentable. + - 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 the wire, yet the consumer branches on it, encoding a belief that cancel-vs-interrupt is a server distinction. The alias table is a second protocol-vocabulary source: a future upstream status would be absorbed or misclassified instead of surfacing as .unknown. Smaller instance: CodexThreadItem.Kind invents .diagnostic/.error while upstream's real hookPrompt variant has no typed kind. Cross-ref conf-interrupted-is-failure, test-handrolled-notification-schemas-drift. + - 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` @@ -736,10 +714,8 @@ CodexDataKit's private semanticID maps enteredReviewMode/exitedReviewMode to rev - 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-fake-cancel-in-flight-returns-success` [protocol-mismatch] Fake send never throws CancellationError for a cancelled in-flight request; the real transport throws and drops the response — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:1094` - - When a task awaiting fake send() is cancelled at a gate, cancelWaiter resumes normally and send() returns the queued response as success; the real transport resumes the pending continuation with CancellationError and discards the response. The 'plain unshielded request cancelled in flight throws and its response is never observed' contract cannot be expressed against the fake, which inverts the outcome to success. SDK-internal sites are shielded (Task.detached), limiting current impact. -- `test-store-invented-error-code` [protocol-mismatch] Test thread store returns invented error code -32004 for missing threads; the real server has no such code — `CodexKit:Sources/CodexAppServerKitTesting/CodexAppServerTestRuntime.swift:126` - - resumeThreadResponse/readThreadResponse/listThreadTurnsResponse throw responseError(code: -32004); upstream codes are -32600/-32601/-32602/-32603/-32001 — no -32004. Error-classification logic validated against the fake pins a code the real server never emits. +- `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` From baabd19cbda014dc869f34e0c18121a9fc33a65d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:54:09 +0900 Subject: [PATCH 03/62] build(package): pin remote CodexKit resolution --- Package.resolved | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 2adb99a..5c72a08 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "6e69018f1a7fb5b827d1f08721f3c54d5891b56910c97b1b90b3f9742625011b", + "originHash" : "b44a75be0a328b8eeb5c53d6cf5558348206171df4b58603650b6a9143072803", "pins" : [ + { + "identity" : "codexkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lynnswap/CodexKit.git", + "state" : { + "revision" : "3f6216c01c91bf14737e6fe40c30efef7a5bbd04" + } + }, { "identity" : "eventsource", "kind" : "remoteSourceControl", From ad0e208b3748bf012318d1bf5d318b56512ccf71 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:03:04 +0900 Subject: [PATCH 04/62] docs(architecture): approve rearchitecture design --- Docs/rearchitecture-2026-07-10.md | 4169 +++++++++++++++++++++++++++++ 1 file changed, 4169 insertions(+) create mode 100644 Docs/rearchitecture-2026-07-10.md diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md new file mode 100644 index 0000000..dadcd88 --- /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 / 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、subscriber | run Task と無期限 terminal historyを所有しない | +| `TurnReplayStore` | active raw generation、weak generation-state registrations | terminal compact snapshotをstateへ渡してraw generationを削除し、自身はterminal snapshot/leaseをretainしない | +| `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` | bounded diagnostics、subscribers、first terminal reason | routerにhistoryを持たせず、late subscriberへcompact terminal 1件だけreplay。explicit close/EOF/process exit raceをactor順で一度だけ裁定 | +| `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、mutation strategy | query/mutationの意味論を call site に漏らさない | +| `CodexModelContainer` | app server とeager MainActor context composition | checked `Sendable`。main context生成/transaction deliveryにfire-and-forget Taskやpending replay bufferを作らない | +| `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` transaction relay | current items/sections/phase、newest full old/new snapshot transaction、subscribers | 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を解放 | +| `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` | package actor(同一isolation) | Taskを作らない | bounded diagnostic subscriber、terminal replay、first-terminal-wins reasonを所有 | +| `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` | checked `Sendable` class。mutable contextは`@MainActor` | Taskを作らない | main contextをeager生成し、transaction deliveryはMainActor structured callをcallerがawait。lazy pending replay Taskを削除 | +| `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` transaction relay | model-context isolationへcaller-confined | Taskを作らない | 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 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 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 cleanupReview( + _ identity: CodexReviewIdentity, + additionalCleanupThreadIDs: [[CodexThreadID]] = [] + ) async + 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 +} +``` + +`prepareReviewRestart` / `restartPreparedReview`は`ReviewRestartCoordinator`へ委譲し、tokenごとのstateを次に固定する。 + +```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しない。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へ保つ。live stdout readerはsingle producerなので17th send completionより先にpipeから18th frameをreadせず、test concurrent producersも各send completionをawaitする。transportが保持するpayload数は常に17以下である。 + +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が一致する。 + +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 feature: String { get } + public var message: String { get } + public var replacement: 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 +} +``` + +- 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`はsupervisor actorが最初に受理したterminal causeを固定する。explicit close requestがEOF/process exit signalより先なら`.closedByCaller`、transport failureが先なら`.transportFailure`、process waiter exitが先なら`.processExited`である。後着causeはdiagnosticへ残すだけでpublic terminalを上書きしない。hubは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`だけを提供する。 +- 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へ使わない。 +- 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送信前に generation leaseを登録し、request中に届くearly eventを同じleaseへ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 + +```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: Sendable { + public let appServer: CodexAppServer + @MainActor public let mainContext: CodexModelContext + + @MainActor public init(appServer: CodexAppServer) + @MainActor public convenience init( + configuration: CodexAppServer.Configuration = .init() + ) async throws +} + +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 { + public init(_ container: CodexModelContainer) + + public nonisolated(nonsending) func fetch( + _ descriptor: CodexFetchDescriptor + ) async throws -> [Model] + public nonisolated(nonsending) func fetch( + _ request: CodexFetchRequest + ) async throws -> [Model] + public func fetchedResults( + for descriptor: CodexFetchDescriptor, + sectionedBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResults + public func fetchedResults( + for request: CodexFetchRequest, + sectionedBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResults + public func fetchedResultsController( + for descriptor: CodexFetchDescriptor, + sectionedBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResultsController + public func fetchedResultsController( + for request: CodexFetchRequest, + sectionedBy: CodexSectionDescriptor? = nil + ) -> CodexFetchedResultsController + + 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 +} + +// 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 + // 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 includePendingChanges: Bool + + public init( + predicate: Predicate? = nil, + sortBy: [CodexSortDescriptor] = [], + fetchLimit: Int? = nil, + fetchOffset: Int? = nil, + includePendingChanges: Bool = true + ) +} + +public final class CodexFetchRequest { + public var predicate: Predicate? + public var sortDescriptors: [CodexSortDescriptor] + public var fetchLimit: Int? + public var fetchOffset: Int? + public var includePendingChanges: Bool + public var fetchDescriptor: CodexFetchDescriptor { get set } + + public init( + predicate: Predicate? = nil, + sortDescriptors: [CodexSortDescriptor] = [], + fetchLimit: Int? = nil, + fetchOffset: Int? = nil, + includePendingChanges: Bool = true + ) + public init(_ descriptor: CodexFetchDescriptor) +} + +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(), + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + _ request: CodexFetchRequest, + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort: [CodexSortDescriptor] = [], + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + order: SortOrder = .forward, + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + order: SortOrder = .forward, + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + animation: Animation? = nil, + sectionBy: CodexSectionDescriptor? = nil + ) + public init( + filter: Predicate? = nil, + sort keyPath: any KeyPath & Sendable, + comparator: String.StandardComparator = .localizedStandard, + order: SortOrder = .forward, + animation: Animation? = nil, + 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 nonisolated(nonsending) func performFetch() async throws + public nonisolated(nonsending) func refresh() async throws + public nonisolated(nonsending) func loadNextPage() async throws +} + +public final class CodexFetchedResultsController { + public let fetchedResults: CodexFetchedResults + public var modelContext: CodexModelContext { get } + public var fetchDescriptor: CodexFetchDescriptor { get } + public var sectionBy: CodexSectionDescriptor? { get } + public var items: [Model] { get } + public var sections: [CodexFetchSection] { get } + public var snapshot: CodexFetchedResultsSnapshot { get } + public var nextCursor: String? { get } + public var backwardsCursor: String? { get } + public var phase: CodexFetchPhase { get } + public var transactions: AsyncStream> { get } + + public init(fetchedResults: CodexFetchedResults) + 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(id: String, turnID: CodexTurnID) + case itemTextAppended(id: String, turnID: CodexTurnID, delta: String) + case statusChanged(CodexThreadStatus?) + case phaseChanged(CodexChatPhase) +} +``` + +- 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生成をしない。 +- 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不能を検証する。 +- `CodexQuery` のkey-path convenience initializer familyも`CodexSortDescriptor`と同じrequired/optional ComparableおよびString comparator + `SortOrder`へ置換する。`CodexQueryResults` / `CodexFetchedResults` / controllerの`lastErrorDescription`は削除し、read-only `CodexFetchPhase`だけをfailure ownerにする。 +- FRC transactionは`oldSnapshot/newSnapshot`のfull identity snapshotを常に含み、public streamは`bufferingNewest(1)`とする。consumerのcurrent snapshotがtransaction.oldと一致すればgranular changesを適用し、不一致ならnew snapshotへreplaceするため、中間transaction dropでも整合する。FRC deinit/explicit owning view teardownで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`、`CodexFetchRequest.sortDescriptors`、`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` 注入は行わない。 +- `.both` archive scopeはserver cursorを共有できないmulti-scope queryとして扱う。`archived:false` と `archived:true` を別々にserver endまで全page取得し、eligible pending/live context modelsをmerge(`includePendingChanges == false` なら省略)→ thread IDでdedupe → literal predicate → local effective sort → local offset → local limitの順に適用する。effective sortは明示descriptor、空ならpinned upstream `thread/list` と同じ `createdAt` descending、その後はprimary descriptorと同方向のthread ID tie-breakである。server-ordered single-page pathや一方のcursorをもう一方へ流用する経路へ入れない。`.both` resultは取得時点でserver endなので、そのsnapshotに対する `loadNextPage()` はno-opである。 +- 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はIDを直前baselineから削除し、consumerがlive `observation.chat`を再読しないと適用できないID-only insert/updateは削除する。insert先に同IDが存在する、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を持たず、`CodexTranscript(items: refreshedItems.map(\.threadItem)).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`は同じgeneration handle stateのcompact SDK terminalをnonblockingに読み、同じobserved mapperへ通すpackage operationで、未知ならnilを返しwire request/collector/barrierを開始しない。これは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とitemID/turnID→projected block IDsのindexを持ち、次の表どおりevent payloadだけをexact cursor順に適用する。target IDが直前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(id:turnID:))` | indexed item blocksをremove。last review markerなら同turnのretained user itemsだけreinsert | +| `.update(.itemTextAppended(id:turnID:delta:))` | exact prior cursorのaffected 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/cleanup、model/account/rate/config read-write、stock `loginChatGPT`/logoutだけを残す。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:)`, `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` | `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` | current-v2/stock-login/typed-outcome contractへ全面移行 | 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`, `CodexFetchRequest`, `CodexQuery`, `CodexQueryResults`; query environment modifiers | §5.6のpredicate/sort/limit/offset/pending fieldsとtyped phase。initializer crash/string failureを除く | ReviewUI, external fixture | +| `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`, `CodexFetchedResultsController` | typed phase、newest-1 relay lifecycle。String error fieldなし | ReviewUI and package UI tests | +| `NEW` | `CodexTurnTerminalDisposition`, `CodexChatPhase`, `CodexChatSnapshotReason`, `CodexChatObservationSnapshot`, `CodexChatObservationEvent`/`Payload` | immutable terminal/failure/snapshot/cursor facts | 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/FRC | 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 | +| 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、nil active-only、`.both` active+archived/pending merge/dedupe/explicit-or-createdAt-desc sort + same-direction ID tie-break/offset/limit with pending on/off、Mirror 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、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する。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が残らない。 From 9a10d28acc30bd866d4aa9ff31e636c16c342043 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:18:38 +0900 Subject: [PATCH 05/62] refactor(package): import CodexKit products directly --- Package.swift | 22 ++++++++++++------- .../AppServerCodexReviewBackend.swift | 2 +- .../LiveCodexReviewStoreBackend.swift | 2 +- .../CodexReviewMCPServer.swift | 3 ++- .../ReviewMCPLogProjection.swift | 2 +- .../ReviewMonitorCodexChatLogProjection.swift | 3 ++- ...wMonitorCodexChatLogSourceProjection.swift | 2 +- .../ReviewMonitorCodexChatLogTarget.swift | 3 ++- ...ReviewMonitorTransportViewController.swift | 3 ++- .../ReviewMonitorCodexModelSource.swift | 2 +- ...ewMonitorCodexSelectionTitleResolver.swift | 3 ++- .../ReviewUI/ReviewMonitorContentView.swift | 2 +- Sources/ReviewUI/ReviewMonitorSelection.swift | 3 ++- .../ReviewMonitorSplitViewController.swift | 2 +- Sources/ReviewUI/ReviewMonitorUIState.swift | 2 +- .../ReviewMonitorWindowController.swift | 2 +- .../ReviewMonitorChatContextMenuView.swift | 3 ++- .../CodexChats/ReviewMonitorChatRowView.swift | 2 +- .../ReviewMonitorCodexSidebarOutline.swift | 3 ++- .../ReviewMonitorSidebarViewController.swift | 3 ++- ...ReviewMonitorPreviewAppServerRuntime.swift | 3 ++- .../ReviewMonitorPreviewComposition.swift | 1 - .../ReviewMonitorPreviewContent.swift | 3 ++- .../CodexReviewMCPHTTPServerTests.swift | 1 - .../ReviewMCPLogProjectionTests.swift | 2 +- .../ReviewMonitorChatLogTesting.swift | 3 ++- .../ReviewMonitorCodexChatDetailTests.swift | 3 ++- ...itorCodexSelectionTitleResolverTests.swift | 3 ++- ...eviewMonitorCodexSidebarResultsTests.swift | 3 ++- Tests/ReviewUITests/ReviewUIShellTests.swift | 3 ++- Tests/ReviewUITests/ReviewUITests.swift | 3 ++- 31 files changed, 59 insertions(+), 38 deletions(-) diff --git a/Package.swift b/Package.swift index eecb848..49c6208 100644 --- a/Package.swift +++ b/Package.swift @@ -60,8 +60,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 +71,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 +86,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 +112,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 +124,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 +134,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", @@ -169,7 +173,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 +199,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 c6ad9a6..be5d9a2 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 diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index fedc387..1639960 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,8 +1,8 @@ import AppKit import Foundation import OSLog -import CodexKit import CodexAppServerKit +import CodexDataKit import CodexReviewKit import CodexReviewAppServer import CodexReviewMCPServer diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index 3b4b725..12ca0dc 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 { diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 3e1682c..c4ff1a6 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 { diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index 2aa11aa..ea74b75 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -1,4 +1,5 @@ -import CodexKit +import CodexAppServerKit +import CodexDataKit import Foundation @MainActor diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 0f25164..597a6ce 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -1,4 +1,4 @@ -import CodexKit +import CodexDataKit import Foundation @MainActor diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index 804bd71..87d6e7f 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") diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index d789e10..20b2685 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/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift index 58fc3a6..efe2f65 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -1,4 +1,4 @@ -import CodexKit +import CodexDataKit import Observation @MainActor diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift index 3a97560..d68d465 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 { diff --git a/Sources/ReviewUI/ReviewMonitorContentView.swift b/Sources/ReviewUI/ReviewMonitorContentView.swift index a760251..285eed0 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 27093fa..03d80e8 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 072fdea..2b1e656 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 2aba199..1c7d708 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -1,5 +1,5 @@ import Observation -import CodexKit +import CodexAppServerKit import CodexReviewKit @MainActor diff --git a/Sources/ReviewUI/ReviewMonitorWindowController.swift b/Sources/ReviewUI/ReviewMonitorWindowController.swift index 870526b..29312d0 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -1,5 +1,5 @@ import AppKit -import CodexKit +import CodexDataKit import CodexReviewKit @MainActor diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 5292e4e..74eb021 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -1,5 +1,6 @@ import AppKit -import CodexKit +import CodexAppServerKit +import CodexDataKit import CodexReviewKit import SwiftUI diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatRowView.swift index f7060d0..b294327 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 b2d52f3..e5ae5ae 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 0e84810..242c29e 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 diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 51572de..747e6c2 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 diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index ec7a6cc..1b2ae95 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 diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index b8e3af0..8adf775 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,5 +1,6 @@ import Foundation -import CodexKit +import CodexAppServerKit +import CodexDataKit @_spi(Testing) import CodexReviewKit import ReviewUI diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 64501b3..bea38e1 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -1,6 +1,5 @@ import Darwin import Foundation -import CodexKit import MCP @preconcurrency import NIOCore import Testing diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 074e62a..7a33749 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -1,6 +1,6 @@ import Foundation import Testing -import CodexKit +import CodexDataKit @testable import CodexReviewKit @testable import CodexReviewMCPServer diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 4118e7e..3d154b2 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -1,6 +1,7 @@ import Foundation import Testing -import CodexKit +import CodexAppServerKit +import CodexDataKit @_spi(Testing) @testable import CodexReviewKit @testable import ReviewChatLogUI @testable import ReviewUI diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index fb56b0f..21a4766 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 diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index 456aeae..a083bb0 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 diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift index 4c1ec23..a371778 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 diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 8d8854b..b460812 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 diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index c5e3c11..fe0bd15 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 From 128b42ce31dcec99293f076e5c4e6857575cc24e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:06:29 +0900 Subject: [PATCH 06/62] refactor(review): consume typed terminal outcomes --- .../AppServerCodexReviewBackend.swift | 272 ++++++------------ .../ReviewBackendEventSession.swift | 30 +- .../CodexReviewKit/CodexReviewBackend.swift | 2 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 66 ++++- .../Store/CodexReviewStoreReviews.swift | 11 +- .../AppServerClientTests.swift | 184 ++++++++---- .../ReviewBackendEventSessionTests.swift | 16 ++ .../CodexReviewStoreCommandTests.swift | 26 +- .../CodexReviewMCPHTTPServerTests.swift | 2 +- 9 files changed, 347 insertions(+), 262 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index be5d9a2..72a293e 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -184,18 +184,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { guard abandonedReviewAttemptIDs.contains(run.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 cancelReviewTurn(for: run) } package func prepareReviewRestart( @@ -260,7 +249,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { var completedMetrics: ReviewBackendEventSessionMetrics? var additionalCleanupThreadIDs: [String] = [] if let session = unregisterReviewEventSession(for: run) { - await session.finish(cancellationMessage: nil) + await session.finish() let metrics = await session.metricsSnapshot() additionalCleanupThreadIDs = await session.cleanupThreadIDs() completedMetrics = metrics @@ -440,16 +429,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { 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 cancelReviewTurn( for run: CodexReviewBackendModel.Review.Run ) async throws -> CodexTurnCancellation { @@ -506,152 +485,93 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } -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 - ) - ], - controlThreadID: review.reviewThreadID.rawValue + ) -> CodexReviewBackendModel.Review.Event { + .started( + turnID: review.turnID.rawValue, + reviewThreadID: review.reviewThreadID.rawValue, + model: run.model ) } 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) - } - if response.status?.isFailure == true { - return terminalFailureEvents( - status: response.status, - message: response.status?.rawValue ?? "Failed." + _ outcome: CodexTurnOutcome + ) -> CodexReviewBackendModel.Review.Event { + switch outcome { + case .completed(let response): + guard let finalReview = response.transcript.reviewOutputText?.nilIfEmpty else { + return .failed(.missingReviewOutput(turnID: response.turnID.rawValue)) + } + return .completed(finalReview: finalReview) + case .interrupted: + return .interrupted(message: nil) + case .failed(let failedTurn): + return .failed(.turnFailed(map(failedTurn.error))) + case .invalidTerminalStatus(let rawStatus, let error, let response): + return .failed( + .invalidTerminalStatus( + rawStatus: rawStatus, + turnID: response.turnID.rawValue, + turnFailure: error.map(map) + ) ) } - guard let finalReview = reviewCompletionText(for: response) else { - return [.failed("Review completed without review output.")] - } - 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)] - } + private static func map(_ error: CodexTurnError) -> ReviewTurnFailure { + .init( + message: error.message, + code: error.info.map(map), + additionalDetails: error.additionalDetails + ) } - private static func itemEvents( - _ item: CodexThreadItem, - phase: AppServerTypedItemPhase - ) -> [CodexReviewBackendModel.Review.Event] { - guard item.kind.rawValue != "enteredReviewMode" else { - return [] + private static func map(_ 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 terminalCollectionTask: Task? private var reviewSession: CodexReviewSession? init( @@ -687,26 +607,18 @@ private actor AppServerReviewEventSession { 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() async { + cancelTerminalCollection() + await pipeline.finish(throwing: nil) } func finish(throwing error: (any Error)?) async { - cancelTypedReviewStream() + cancelTerminalCollection() await pipeline.finish(throwing: error) } func abandon() async { - cancelTypedReviewStream() + cancelTerminalCollection() await pipeline.abandon() } @@ -721,11 +633,11 @@ private actor AppServerReviewEventSession { func detach(subscriptionID _: Int) {} func startConsuming(_ review: CodexReviewSession) { - guard typedReviewStreamTask == nil else { + guard terminalCollectionTask == nil else { return } reviewSession = review - typedReviewStreamTask = Task { [weak self] in + terminalCollectionTask = Task { [weak self] in await self?.consume(review) } } @@ -741,21 +653,22 @@ private actor AppServerReviewEventSession { private func consume(_ review: CodexReviewSession) async { defer { - typedReviewStreamTask = nil + terminalCollectionTask = nil if reviewSession?.id == review.id { reviewSession = nil } } let run = await pipeline.currentRun() - await receive(AppServerTypedReviewEventAdapter.started(review: review, run: run)) + await receive( + AppServerTypedReviewEventAdapter.started(review: review, run: run), + controlThreadID: review.reviewThreadID.rawValue + ) do { - for try await event in review.events { - if Task.isCancelled { - return - } - await receive(AppServerTypedReviewEventAdapter.convert(event, review: review)) - } - await finish(throwing: nil) + let outcome = try await review.collect() + await receive( + AppServerTypedReviewEventAdapter.convert(outcome), + controlThreadID: review.reviewThreadID.rawValue + ) } catch is CancellationError { await finish(throwing: CancellationError()) } catch { @@ -763,13 +676,16 @@ private actor AppServerReviewEventSession { } } - private func receive(_ converted: AppServerTypedReviewEvent) async { - await pipeline.receive(converted.events, controlThreadID: converted.controlThreadID) + private func receive( + _ event: CodexReviewBackendModel.Review.Event, + controlThreadID: String + ) async { + await pipeline.receive([event], controlThreadID: controlThreadID) } - private func cancelTypedReviewStream() { - typedReviewStreamTask?.cancel() - typedReviewStreamTask = nil + private func cancelTerminalCollection() { + terminalCollectionTask?.cancel() + terminalCollectionTask = nil reviewSession = nil } } diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift index 6ddb464..d0ba336 100644 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift @@ -39,7 +39,6 @@ package actor ReviewBackendEventSession { 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() @@ -78,19 +77,6 @@ package actor ReviewBackendEventSession { 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 @@ -137,18 +123,6 @@ package actor ReviewBackendEventSession { } } - 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, @@ -176,7 +150,7 @@ package actor ReviewBackendEventSession { switch event { case .started(let turnID, _, _): await callbacks.recordTurnStarted(turnID) - case .completed, .failed, .cancelled: + case .completed, .interrupted, .failed, .cancelled: await callbacks.recordFinished(run, metrics) } } @@ -203,7 +177,7 @@ package actor ReviewBackendEventSession { private extension CodexReviewBackendModel.Review.Event { var isReviewBackendTerminal: Bool { switch self { - case .completed, .failed, .cancelled: + case .completed, .interrupted, .failed, .cancelled: true case .started: false diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index f891574..afca4ce 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -176,7 +176,7 @@ package actor BackendReviewEventMailbox { private static func isTerminal(_ event: CodexReviewBackendModel.Review.Event) -> Bool { switch event { - case .completed, .failed, .cancelled: + case .completed, .interrupted, .failed, .cancelled: return true case .started: return false diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 82a6029..1f78cf2 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -285,11 +285,75 @@ package extension CodexReviewBackendModel.Review { } } +package struct ReviewTurnFailure: Equatable, Sendable { + package enum Code: Equatable, 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 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 enum ReviewBackendFailure: Error, Equatable, Sendable { + case message(String) + case missingReviewOutput(turnID: String) + case invalidTerminalStatus( + rawStatus: String, + turnID: String, + turnFailure: ReviewTurnFailure? + ) + case turnFailed(ReviewTurnFailure) + case interruptedByBackend(message: String?) + + package var message: String { + switch self { + case .message(let message): + message + case .missingReviewOutput: + "Review completed without review output." + 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." + } + } +} + 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 interrupted(message: String?) + case failed(ReviewBackendFailure) case cancelled(String) } } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index 5929fff..b5e7592 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -826,8 +826,13 @@ extension CodexReviewStore { runRecord.core.lifecycleMessage = "Review started." case .completed(let completion): completeReview(runRecord, finalReview: completion.finalReview) - case .failed(let message): - markReviewFailed(runRecord, message: message) + case .interrupted(let message): + markReviewFailed( + runRecord, + message: ReviewBackendFailure.interruptedByBackend(message: message).message + ) + case .failed(let failure): + markReviewFailed(runRecord, message: failure.message) case .cancelled(let message): let cancellation = runRecord.core.lifecycle.cancellation ?? .system(message: message) try? completeCancellationLocally( @@ -889,7 +894,7 @@ extension CodexReviewStore { private extension CodexReviewBackendModel.Review.Event { var completesReviewRun: Bool { switch self { - case .completed, .failed, .cancelled: + case .completed, .interrupted, .failed, .cancelled: true case .started: false diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index bd00a8d..832a084 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -135,35 +135,6 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(finalReview: "No issues found.")) } - @Test func backendCompletesReviewFromCompatibleFinalAnswerWhenReviewOutputIsAbsent() 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") - await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(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.")) - } - @Test func backendDoesNotPromoteThreadScopedFinalAnswerDeltaWithoutTurnID() async throws { let runtime = try await CodexAppServerTestRuntime.start() try await runtime.transport.enqueueThreadStart(threadID: "thread-1", model: "gpt-5") @@ -193,7 +164,9 @@ struct AppServerClientTests { #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.")) + #expect( + try await iterator.next() + == .failed(.missingReviewOutput(turnID: "turn-1"))) } @Test func backendDoesNotPromoteThreadlessAgentMessageToInlineReview() async throws { @@ -220,7 +193,9 @@ struct AppServerClientTests { #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.")) + #expect( + try await iterator.next() + == .failed(.missingReviewOutput(turnID: "turn-1"))) } @Test func backendFailsCompletedReviewWithoutReviewOutput() async throws { @@ -243,7 +218,121 @@ struct AppServerClientTests { #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.")) + #expect( + try await iterator.next() + == .failed(.missingReviewOutput(turnID: "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: "review-thread") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = AppServerCodexReviewBackend(appServer: runtime.server) + + let attempt = try await backend.startReview(makeReviewStart()) + var iterator = eventSequence(attempt).makeAsyncIterator() + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init( + id: "turn-1", + status: "failed", + error: .init( + message: "Capacity exhausted.", + codexErrorInfo: "serverOverloaded", + additionalDetails: "retry after the maintenance window" + ) + ) + ) + ) + + #expect( + try await iterator.next() + == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + #expect( + try await iterator.next() + == .failed( + .turnFailed( + .init( + message: "Capacity exhausted.", + code: .serverOverloaded, + additionalDetails: "retry after the maintenance window" + ) + ) + ) + ) + } + + @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") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = AppServerCodexReviewBackend(appServer: runtime.server) + + let attempt = try await backend.startReview(makeReviewStart()) + var iterator = eventSequence(attempt).makeAsyncIterator() + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init(id: "turn-1", status: "interrupted") + ) + ) + + #expect( + try await iterator.next() + == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + #expect(try await iterator.next() == .interrupted(message: nil)) + } + + @Test func backendPreservesUnknownTerminalStatusError() 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") + await runtime.transport.waitForNotificationStreamCount(1) + let backend = AppServerCodexReviewBackend(appServer: runtime.server) + + let attempt = try await backend.startReview(makeReviewStart()) + var iterator = eventSequence(attempt).makeAsyncIterator() + + try await runtime.transport.emitServerNotification( + method: "turn/completed", + params: TestTurnNotification( + threadID: "review-thread", + turn: .init( + id: "turn-1", + status: "pausedByFutureServer", + error: .init( + message: "Future terminal detail.", + codexErrorInfo: "futureErrorCode", + additionalDetails: "future additional detail" + ) + ) + ) + ) + + #expect( + try await iterator.next() + == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) + #expect( + try await iterator.next() + == .failed( + .invalidTerminalStatus( + rawStatus: "pausedByFutureServer", + turnID: "turn-1", + turnFailure: .init( + message: "Future terminal detail.", + code: .unknown(rawValue: "futureErrorCode"), + additionalDetails: "future additional detail" + ) + ) + ) + ) } @Test func backendIgnoresAgentMessageDeltasInLifecycleStream() async throws { @@ -718,14 +807,27 @@ private struct TestTurn: Encodable, Sendable { var id: String var status: String var items: [TestItem]? + var error: TestTurnError? - init(id: String, status: String, items: [TestItem]? = nil) { + init( + id: String, + status: String, + items: [TestItem]? = nil, + error: TestTurnError? = nil + ) { self.id = id self.status = status self.items = items + self.error = error } } +private struct TestTurnError: Encodable, Sendable { + var message: String + var codexErrorInfo: String? + var additionalDetails: String? +} + private struct TestDeltaNotification: Encodable, Sendable { var threadID: String var turnID: String @@ -776,8 +878,6 @@ private struct TestItemNotification: Encodable, Sendable { 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? @@ -788,8 +888,6 @@ private struct TestItem: Encodable, Sendable { init( type: String, id: String, - text: String? = nil, - phase: String? = nil, review: String? = nil, command: String? = nil, cwd: String? = nil, @@ -799,8 +897,6 @@ private struct TestItem: Encodable, Sendable { ) { self.type = type self.id = id - self.text = text - self.phase = phase self.review = review self.command = command self.cwd = cwd @@ -809,16 +905,6 @@ private struct TestItem: Encodable, Sendable { self.status = status } - static func finalAnswer(id: String, text: String) -> TestItem { - .init( - type: "agentMessage", - id: id, - text: text, - phase: "final_answer", - status: "completed" - ) - } - static func exitedReviewMode(id: String, review: String) -> TestItem { .init( type: "exitedReviewMode", diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift index 6a46c04..9f0eb6b 100644 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift @@ -47,6 +47,22 @@ struct ReviewBackendEventSessionTests { #expect(metrics.terminalLatencyMs != nil) #expect(await recorder.finishedMetrics() == metrics) } + + @Test func callerCancellationDoesNotBecomeServerInterruption() async throws { + let interruptedSession = ReviewBackendEventSession(run: makeRun()) + let interruptedAttempt = await interruptedSession.attempt() + await interruptedSession.receive([.interrupted(message: nil)]) + + #expect(try await nextEvent(from: interruptedAttempt.events) == .interrupted(message: nil)) + + let cancelledSession = ReviewBackendEventSession(run: makeRun()) + let cancelledAttempt = await cancelledSession.attempt() + await cancelledSession.finish(throwing: CancellationError()) + + await #expect(throws: CancellationError.self) { + try await cancelledAttempt.events.next() + } + } } private actor ReviewBackendEventSessionRecorder { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 6390d2c..8aa2264 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1319,6 +1319,30 @@ struct CodexReviewStoreCommandTests { } } + @Test func serverInterruptionWithoutPendingCancellationFailsReview() async throws { + let backend = FakeCodexReviewBackend() + 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: .baseBranch("main")) + ) + try #require( + await StoreSnapshotProbe(store: store) + .waitUntilRunStatus(.running, runID: "run-1") != nil + ) + + await backend.yield(.interrupted(message: nil)) + let read = try await result + + #expect(read.core.lifecycle.status == .failed) + #expect(read.core.lifecycle.errorMessage == "Review was interrupted by the backend.") + } + } + @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() async throws { let initialRun = CodexReviewBackendModel.Review.Run( threadID: "thread-1", @@ -1502,7 +1526,7 @@ struct CodexReviewStoreCommandTests { ) async let cancel = store.cancelReview(runID: "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)) await interruptGate.open() _ = try await cancel let read = try await result diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index bea38e1..6969725 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -440,7 +440,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.failed("Backend failed")) + await backend.yield(.failed(.message("Backend failed"))) let resolved = try decodeSSEJSON(from: try await responseData) #expect(resolved.value(for: ["result", "isError"]) as? Bool == true) From bdae8ef076e2626856b468fa481216819b27ccf4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:06:23 +0900 Subject: [PATCH 07/62] refactor(review-ui): adopt legal turn snapshot states --- .../ReviewMonitorCodexChatLogProjection.swift | 6 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 14 +- .../ReviewMonitorPreviewContent.swift | 29 +++-- .../ReviewMonitorChatLogTesting.swift | 40 ++++-- .../ReviewMonitorCodexChatDetailTests.swift | 120 +++++++++++++----- Tests/ReviewUITests/ReviewUIShellTests.swift | 10 +- Tests/ReviewUITests/ReviewUITests.swift | 3 +- 7 files changed, 148 insertions(+), 74 deletions(-) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index ea74b75..d88de9f 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -593,14 +593,14 @@ 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 } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 747e6c2..71fba5d 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -351,8 +351,8 @@ final class ReviewMonitorPreviewAppServerRuntime { 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 + if turn.state.isTerminalForPreview == false { + turn.state = .interrupted } return turn } @@ -721,7 +721,7 @@ final class ReviewMonitorPreviewAppServerRuntime { threadID: fixture.chatID.rawValue, turn: .init( id: turnID.rawValue, - status: "cancelled", + status: "interrupted", completedAt: Int(Date().timeIntervalSince1970) ) ) @@ -802,12 +802,12 @@ private struct PreviewTurnCompletedParams: Encodable, Sendable { } } -private extension Optional where Wrapped == CodexTurnStatus { +private extension CodexTurnSnapshot.State { var isTerminalForPreview: Bool { switch self { - case .completed?, .failed?, .interrupted?, .cancelled?: + case .completed, .failed, .interrupted: true - case .running?, .unknown?, nil: + case .inProgress, .unknown: false } } @@ -972,7 +972,7 @@ private extension CodexThreadSnapshot { if let existingTurnID = turns?.last?.id { return existingTurnID } - turns = [CodexTurnSnapshot(id: turnID, status: .running)] + turns = [CodexTurnSnapshot(id: turnID, state: .inProgress)] return turnID } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 8adf775..5674ab6 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -357,7 +357,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 @@ -383,7 +383,7 @@ public enum ReviewMonitorPreviewContent { content: .toolCall( .init( result: "MCP codex_review.review_read started.", - status: .running + status: .inProgress )), delayBeforeFrameCount: interItemDelayFrameCount ), @@ -404,7 +404,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 @@ -668,8 +668,10 @@ public enum ReviewMonitorPreviewContent { ) -> 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 @@ -782,7 +784,7 @@ public enum ReviewMonitorPreviewContent { .init( command: command, cwd: cwd, - status: .running + status: .inProgress )) ) } @@ -970,7 +972,7 @@ public enum ReviewMonitorPreviewContent { toolCallItem( "running-tool-\(workspaceName)-\(definition.targetSummary)", result: "MCP codex_review.review_start started.", - status: .running + status: .inProgress ), reasoningItem( "preview-initial-summary-\(workspaceName)-\(definition.targetSummary)", @@ -1062,17 +1064,20 @@ private extension CodexThreadStatus { } } -private extension CodexTurnStatus { - init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle) { +private extension CodexTurnSnapshot.State { + init( + _ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, + failureMessage: String + ) { switch lifecycle { case .queued, .running: - self = .running + self = .inProgress case .succeeded: self = .completed case .failed: - self = .failed + self = .failed(CodexTurnError(message: failureMessage)) case .cancelled: - self = .cancelled + self = .interrupted } } } diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 3d154b2..f1c7eac 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -201,8 +201,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( @@ -502,8 +504,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( @@ -511,8 +512,7 @@ func makeCodexThreadSnapshotForTesting( turns: [ .init( id: turnID, - status: turnStatus, - errorMessage: turnErrorDescription, + state: turnState, items: items ) ] @@ -528,7 +528,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 } @@ -847,17 +852,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 + self = .interrupted } } } @@ -885,7 +896,10 @@ 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 entry.kind == .command ? .inProgress : nil + } + if rawValue == "running" { + return .inProgress } return CodexTurnStatus(rawValue: rawValue) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 21a4766..d3cc4f5 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -10,13 +10,67 @@ import Testing @Suite("ReviewMonitor selected Codex chat detail", .serialized) @MainActor struct ReviewMonitorCodexChatDetailTests { + @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 document = try #require( + projection.render( + from: turn, + chatCreatedAt: nil, + chatUpdatedAt: nil + )) + 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 logResynchronizationCanUpdateAfterInitialBaseline() async throws { let turnID = CodexTurnID(rawValue: "turn-1") let chat = try await makeProjectionChat( turns: [ .init( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "log-1", @@ -62,7 +116,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-1", - status: .running, + state: .inProgress, items: [ .init( id: "message-1", @@ -108,7 +162,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-first", - status: .completed, + state: .completed, items: [ .init( id: "message-first", @@ -148,7 +202,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-second", - status: .completed, + state: .completed, items: [ .init( id: "message-second", @@ -189,7 +243,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-first", - status: .completed, + state: .completed, items: [ .init( id: "message-first", @@ -271,7 +325,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-1", - status: .running, + state: .inProgress, items: [ .init( id: "message-1", @@ -323,7 +377,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-1", - status: .running, + state: .inProgress, items: [ .init( id: "message-1", @@ -393,7 +447,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-1", - status: .running, + state: .inProgress, items: [ .init( id: "message-1", @@ -461,7 +515,7 @@ struct ReviewMonitorCodexChatDetailTests { let turnID = CodexTurnID(rawValue: "turn-review") let turn = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "user-message", @@ -497,7 +551,7 @@ struct ReviewMonitorCodexChatDetailTests { """ let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .completed, + state: .completed, items: [ .init( id: "review-output", @@ -533,7 +587,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: CodexTurnID(rawValue: "turn-first"), - status: .completed, + state: .completed, items: [ .init( id: "review-output-first", @@ -553,7 +607,7 @@ struct ReviewMonitorCodexChatDetailTests { ), .init( id: CodexTurnID(rawValue: "turn-second"), - status: .completed, + state: .completed, items: [ .init( id: "review-output-second", @@ -598,7 +652,7 @@ struct ReviewMonitorCodexChatDetailTests { """ let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -631,7 +685,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Checking diff" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "reasoning-a", @@ -664,7 +718,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Checking diff" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "reasoning-a", @@ -705,7 +759,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: CodexTurnID(rawValue: "turn-first"), - status: .completed, + state: .completed, items: [ .init( id: "reasoning-first", @@ -716,7 +770,7 @@ struct ReviewMonitorCodexChatDetailTests { ), .init( id: CodexTurnID(rawValue: "turn-second"), - status: .completed, + state: .completed, items: [ .init( id: "reasoning-second", @@ -752,13 +806,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", @@ -771,7 +825,7 @@ struct ReviewMonitorCodexChatDetailTests { ) let mirrored = CodexTurnSnapshot( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -812,7 +866,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Inspecting differences" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -850,7 +904,7 @@ struct ReviewMonitorCodexChatDetailTests { let reasoningText = "Inspecting differences" let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), - status: .running, + state: .inProgress, items: [ .init( id: "event-reasoning", @@ -891,7 +945,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"), @@ -915,7 +969,7 @@ struct ReviewMonitorCodexChatDetailTests { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-chat"), - status: .running, + state: .inProgress, items: [ .init( id: "user-message", @@ -941,7 +995,7 @@ struct ReviewMonitorCodexChatDetailTests { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-command"), - status: .completed, + state: .completed, items: [ .init( id: "command-running", @@ -949,7 +1003,7 @@ struct ReviewMonitorCodexChatDetailTests { content: .command(.init( command: "/bin/zsh -lc", output: "done", - status: .running, + status: .inProgress, startedAt: Date(timeIntervalSince1970: 4_000) )) ), @@ -980,7 +1034,7 @@ struct ReviewMonitorCodexChatDetailTests { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-command"), - status: .completed, + state: .completed, items: [ .init( id: "command-failed", @@ -989,7 +1043,7 @@ struct ReviewMonitorCodexChatDetailTests { command: "/bin/zsh -lc", output: "error", exitCode: 1, - status: .running, + status: .inProgress, startedAt: Date(timeIntervalSince1970: 4_000) )) ), @@ -1022,7 +1076,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: turnID, - status: .running, + state: .inProgress, items: [ .init( id: "message-review", @@ -1059,7 +1113,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: firstTurnID, - status: .running, + state: .inProgress, items: [ .init( id: "message-review", @@ -1078,7 +1132,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: firstTurnID, - status: .running, + state: .inProgress, items: [ .init( id: "message-review", @@ -1093,7 +1147,7 @@ struct ReviewMonitorCodexChatDetailTests { ), .init( id: secondTurnID, - status: .running, + state: .inProgress, items: [ .init( id: "reasoning-empty", @@ -1136,7 +1190,7 @@ struct ReviewMonitorCodexChatDetailTests { turns: [ .init( id: "turn-1", - status: .running, + state: .inProgress, items: [ .init( id: "message-1", diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index b460812..5e7cb32 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -127,7 +127,7 @@ 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() @@ -405,7 +405,7 @@ extension ReviewUITests { updatedAt: Date(timeIntervalSince1970: 5_000), status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] ) ] @@ -415,7 +415,7 @@ 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") @@ -494,7 +494,7 @@ extension ReviewUITests { updatedAt: Date(timeIntervalSince1970: 5_000), status: .active(activeFlags: []), turns: [ - .init(id: turnID, status: .running) + .init(id: turnID, state: .inProgress) ] ) ] @@ -504,7 +504,7 @@ 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") diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index fe0bd15..a5bf311 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5635,7 +5635,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 ) } From dc9170213777688527a899fb5f7a02fe46602ecd Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:11:43 +0900 Subject: [PATCH 08/62] test(review-ui): evaluate mutating projection before require --- .../ReviewMonitorCodexChatDetailTests.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index d3cc4f5..6959744 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -50,12 +50,12 @@ struct ReviewMonitorCodexChatDetailTests { ] ) - let document = try #require( - projection.render( - from: turn, - chatCreatedAt: nil, - chatUpdatedAt: nil - )) + 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) From bb8795a67fa774dfaa1425c8edb20983435b5bc3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:36:24 +0900 Subject: [PATCH 09/62] fix(review): preserve runtime stop invariants --- .../AppServerCodexReviewBackend.swift | 14 +++++-- .../Store/CodexReviewStoreCancellation.swift | 38 ++++++++++++++++++- .../Store/CodexReviewStoreRuntimeState.swift | 4 ++ ...wMonitorCommandOutputDisplayDocument.swift | 5 +++ .../AppServerClientTests.swift | 7 ++++ .../CodexReviewHostTests.swift | 11 +++++- .../CodexReviewStoreCommandTests.swift | 16 ++++---- 7 files changed, 80 insertions(+), 15 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 72a293e..6fb46f8 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -36,6 +36,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { // until runtime teardown: cleanupReview defers the destructive thread // deletion here and cleanupActiveReviewsForShutdown flushes it. private var deferredThreadCleanupsByAttemptID: [String: DeferredReviewThreadCleanup] = [:] + private var completedThreadCleanupAttemptIDs: Set = [] package init(appServer: CodexAppServer, modelContainer: CodexModelContainer? = nil) { let modelContainer = modelContainer ?? CodexModelContainer(appServer: appServer) @@ -260,10 +261,12 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { completedReviewEventSessionMetricsByThreadID[threadID] = completedMetrics } } - deferredThreadCleanupsByAttemptID[run.attemptID] = .init( - run: run, - additionalCleanupThreadIDs: additionalCleanupThreadIDs - ) + if completedThreadCleanupAttemptIDs.contains(run.attemptID) == false { + deferredThreadCleanupsByAttemptID[run.attemptID] = .init( + run: run, + additionalCleanupThreadIDs: additionalCleanupThreadIDs + ) + } for threadID in cleanupThreadIDs { reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) @@ -273,6 +276,9 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { private func flushDeferredThreadCleanups() async { let deferredCleanups = deferredThreadCleanupsByAttemptID.values deferredThreadCleanupsByAttemptID = [:] + completedThreadCleanupAttemptIDs.formUnion( + deferredCleanups.map { $0.run.attemptID } + ) for cleanup in deferredCleanups { await cleanupAppServerReview( cleanup.run, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index a9c80b0..1cc435f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -155,8 +155,21 @@ extension CodexReviewStore { CodexReviewRuntimeStopReviewCleanupRequest ) async -> Bool ) async -> CodexReviewRuntimeStopReviewCleanupResult { + let startingRunIDs: [String] = orderedReviewRuns.compactMap { runRecord in + guard runRecord.isTerminal == false, + runtimeState.isStarting(runRecord.id) + else { + return nil + } + return runRecord.id + } + markActiveReviewCancellationsPendingForRuntimeStop(reason: reason) let request = runtimeStopReviewCleanupRequest(reason: reason) - let didCompleteBackendCleanup = await cleanupBackendReviews(request) + let didCompleteInitialBackendCleanup = await cleanupBackendReviews(request) + _ = await drainReviewWorkersForRuntimeStop( + runIDs: startingRunIDs, + timeout: workerDrainTimeout + ) let locallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop( reason: reason, cancelWorkers: false @@ -165,12 +178,25 @@ extension CodexReviewStore { let didDrainReviewWorkers = await drainReviewWorkersForRuntimeStop( timeout: workerDrainTimeout ) + let didCompleteFinalBackendCleanup = await cleanupBackendReviews(request) return .init( - didCompleteBackendCleanup: didCompleteBackendCleanup, + didCompleteBackendCleanup: + didCompleteInitialBackendCleanup && didCompleteFinalBackendCleanup, didDrainReviewWorkers: didDrainReviewWorkers ) } + private func markActiveReviewCancellationsPendingForRuntimeStop( + reason: ReviewCancellation + ) { + for runRecord in orderedReviewRuns where runRecord.isTerminal == false { + runRecord.cancellationRequested = true + runRecord.core.lifecycle.cancellation = reason + runRecord.core.lifecycleMessage = reason.message + runRecord.core.lifecycle.errorMessage = reason.message + } + } + private func runtimeStopReviewCleanupRequest( reason: ReviewCancellation ) -> CodexReviewRuntimeStopReviewCleanupRequest { @@ -225,6 +251,14 @@ extension CodexReviewStore { return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) } + private func drainReviewWorkersForRuntimeStop( + runIDs: [String], + timeout: Duration + ) async -> Bool { + let tasks = runtimeState.activeWorkerTasks(for: runIDs) + return await drainReviewWorkerTasksForRuntimeStop(tasks, timeout: timeout) + } + private func drainReviewWorkerTasksForRuntimeStop( _ tasks: [Task], timeout: Duration diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift index ae64d2c..fe86ab1 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -117,6 +117,10 @@ final class CodexReviewStoreRuntimeState { activeWorkerTasks() + detachedWorkerTasks() } + func activeWorkerTasks(for runIDs: [String]) -> [Task] { + runIDs.compactMap { reviewWorkerTasks[$0] } + } + func clearRuntimeStopState(for runID: String) { removeActiveRun(for: runID) clearWaitingForNetworkRecovery(runID) diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 4ca0eee..94ed256 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -683,6 +683,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/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 832a084..0f561de 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -474,6 +474,13 @@ struct AppServerClientTests { .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: []) ) + // The review worker can reach its own finalizer after shutdown cleanup. + // Repeated finalization must not reacquire destructive cleanup authority. + await backend.cleanupReview(attempt.run) + await backend.cleanupActiveReviewsForShutdown( + .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: []) + ) + 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 } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 4eaeafa..8c9fd4a 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1836,7 +1836,10 @@ struct CodexReviewHostTests { await store.addAccount() 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) } @@ -2126,6 +2129,7 @@ private enum AppServerAPI { private struct Turn: Codable, Equatable, Sendable { var id: String + var status: String } init(turnID: String, reviewThreadID: String? = nil) { @@ -2141,7 +2145,10 @@ private enum AppServerAPI { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Turn(id: turnID), forKey: .turn) + try container.encode( + Turn(id: turnID, status: CodexTurnStatus.inProgress.rawValue), + forKey: .turn + ) try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID) } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 8aa2264..8f773c1 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -870,10 +870,11 @@ struct CodexReviewStoreCommandTests { 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(requests.count == 2) + #expect(requests.allSatisfy { $0.recoveryWaitingRuns == [initialRun] }) #expect(read.core.lifecycle.status == .cancelled) #expect(read.core.lifecycle.cancellation?.message == "Review runtime stopped.") #expect(read.core.run.turnID == "turn-1") @@ -1085,12 +1086,13 @@ struct CodexReviewStoreCommandTests { await recorder.record(request) return true } - let request = try #require(await recorder.onlyRequest()) + let requests = await recorder.recordedRequests() let read = try store.readReview(runID: "run-1") #expect(result.didComplete) - #expect(request.reason.message == "Review runtime stopped.") - #expect(request.recoveryWaitingRuns == [run]) + #expect(requests.count == 2) + #expect(requests.allSatisfy { $0.reason.message == "Review runtime stopped." }) + #expect(requests.allSatisfy { $0.recoveryWaitingRuns == [run] }) #expect(read.core.lifecycle.status == .cancelled) let runtimeState = store.runtimeReviewRunState(runID: "run-1") #expect(runtimeState.hasActiveWorker == false) @@ -1869,8 +1871,8 @@ private actor RuntimeStopCleanupRequestRecorder { requests.append(request) } - func onlyRequest() -> CodexReviewRuntimeStopReviewCleanupRequest? { - requests.count == 1 ? requests[0] : nil + func recordedRequests() -> [CodexReviewRuntimeStopReviewCleanupRequest] { + requests } } From 0e01b09359a0d7804542d4d0ac13a4ddd320fe6f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:50:05 +0900 Subject: [PATCH 10/62] fix(review): preserve typed terminal failures --- Sources/CodexReviewKit/CodexReviewTypes.swift | 6 +- .../CodexReviewKit/Model/ReviewRunCore.swift | 5 +- .../Model/ReviewRunRecordTesting.swift | 5 +- .../Store/CodexReviewStoreCancellation.swift | 4 + .../Store/CodexReviewStoreReviews.swift | 16 +++- .../CodexReviewMCPToolResults.swift | 87 +++++++++++++++++++ .../CodexReviewStoreCommandTests.swift | 39 +++++++++ .../CodexReviewMCPHTTPServerTests.swift | 35 +++++++- 8 files changed, 189 insertions(+), 8 deletions(-) diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 1f78cf2..156f828 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -285,8 +285,8 @@ package extension CodexReviewBackendModel.Review { } } -package struct ReviewTurnFailure: Equatable, Sendable { - package enum Code: Equatable, Sendable { +package struct ReviewTurnFailure: Codable, Hashable, Sendable { + package enum Code: Codable, Hashable, Sendable { case contextWindowExceeded case sessionBudgetExceeded case usageLimitExceeded @@ -321,7 +321,7 @@ package struct ReviewTurnFailure: Equatable, Sendable { } } -package enum ReviewBackendFailure: Error, Equatable, Sendable { +package enum ReviewBackendFailure: Error, Codable, Hashable, Sendable { case message(String) case missingReviewOutput(turnID: String) case invalidTerminalStatus( diff --git a/Sources/CodexReviewKit/Model/ReviewRunCore.swift b/Sources/CodexReviewKit/Model/ReviewRunCore.swift index 3897a28..4cbbced 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -30,6 +30,7 @@ package struct ReviewRunCore: Codable, Sendable, Hashable { package var endedAt: Date? package var cancellation: ReviewCancellation? package var errorMessage: String? + package var failure: ReviewBackendFailure? package init( status: ReviewRunState, @@ -37,7 +38,8 @@ package struct ReviewRunCore: Codable, Sendable, Hashable { startedAt: Date? = nil, endedAt: Date? = nil, cancellation: ReviewCancellation? = nil, - errorMessage: String? = nil + errorMessage: String? = nil, + failure: ReviewBackendFailure? = nil ) { self.status = status self.exitCode = exitCode @@ -45,6 +47,7 @@ package struct ReviewRunCore: Codable, Sendable, Hashable { self.endedAt = endedAt self.cancellation = cancellation self.errorMessage = errorMessage + self.failure = failure } } diff --git a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift index 49c8f5e..0671518 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunRecordTesting.swift @@ -16,6 +16,7 @@ extension ReviewRunRecord { endedAt: Date? = nil, summary: String, errorMessage: String? = nil, + failure: ReviewBackendFailure? = nil, exitCode: Int? = nil ) -> ReviewRunRecord { ReviewRunRecord( @@ -36,7 +37,9 @@ extension ReviewRunRecord { startedAt: startedAt, endedAt: endedAt, cancellation: cancellation, - errorMessage: errorMessage + errorMessage: errorMessage, + failure: failure + ?? (status == .failed ? .message(errorMessage ?? summary) : nil) ), lifecycleMessage: summary ), diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 1cc435f..721eac2 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -49,6 +49,7 @@ extension CodexReviewStore { runRecord.cancellationRequested = false runRecord.core.lifecycle.cancellation = cancellation runRecord.core.lifecycle.status = .cancelled + runRecord.core.lifecycle.failure = nil runRecord.core.lifecycleMessage = cancellation.message runRecord.core.lifecycle.errorMessage = cancellation.message.nilIfEmpty @@ -310,6 +311,9 @@ extension CodexReviewStore { resolvedError ?? reason.nilIfEmpty ?? runRecord.core.lifecycle.errorMessage + runRecord.core.lifecycle.failure = .message( + runRecord.core.lifecycle.errorMessage ?? runRecord.core.lifecycleMessage + ) runRecord.core.lifecycle.endedAt = clock.now() } noteReviewRunMutation() diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index b5e7592..ccb1588 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -544,19 +544,29 @@ extension CodexReviewStore { private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { runRecord.core.lifecycle.status = .running runRecord.core.lifecycle.startedAt = startedAt + runRecord.core.lifecycle.failure = nil runRecord.core.lifecycleMessage = "Review started." runRecord.core.finalReview = nil writeDiagnosticsIfNeeded() } private func markReviewFailed(_ runRecord: ReviewRunRecord, message: String) { + markReviewFailed(runRecord, failure: .message(message)) + } + + private func markReviewFailed( + _ runRecord: ReviewRunRecord, + failure: ReviewBackendFailure + ) { guard runRecord.isTerminal == false else { return } let endedAt = clock.now() + let message = failure.message runRecord.core.lifecycle.status = .failed runRecord.core.lifecycle.endedAt = endedAt runRecord.core.lifecycle.errorMessage = message + runRecord.core.lifecycle.failure = failure runRecord.core.lifecycleMessage = message runRecord.core.finalReview = nil writeDiagnosticsIfNeeded() @@ -829,10 +839,10 @@ extension CodexReviewStore { case .interrupted(let message): markReviewFailed( runRecord, - message: ReviewBackendFailure.interruptedByBackend(message: message).message + failure: .interruptedByBackend(message: message) ) case .failed(let failure): - markReviewFailed(runRecord, message: failure.message) + markReviewFailed(runRecord, failure: failure) case .cancelled(let message): let cancellation = runRecord.core.lifecycle.cancellation ?? .system(message: message) try? completeCancellationLocally( @@ -869,6 +879,8 @@ extension CodexReviewStore { let endedAt = clock.now() runRecord.core.lifecycle.status = .succeeded runRecord.core.lifecycle.endedAt = endedAt + runRecord.core.lifecycle.errorMessage = nil + runRecord.core.lifecycle.failure = nil runRecord.core.lifecycleMessage = "Review completed." runRecord.core.finalReview = finalReview writeDiagnosticsIfNeeded() diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 515cb0d..dd22981 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -420,10 +420,97 @@ private extension ReviewRunCore.Lifecycle { "cancellable": .bool(cancellable), "cancellation": cancellation.map { $0.structuredContent() } ?? .null, "errorMessage": errorMessage.map(Value.string) ?? .null, + "failure": failure.map { $0.structuredContent() } ?? .null, ]) } } +private extension ReviewBackendFailure { + func structuredContent() -> Value { + var object: [String: Value] = [ + "message": .string(message), + ] + switch self { + case .message: + object["kind"] = .string("message") + case .missingReviewOutput(let turnID): + object["kind"] = .string("missingReviewOutput") + object["turnId"] = .string(turnID) + case .invalidTerminalStatus(let rawStatus, let turnID, let turnFailure): + object["kind"] = .string("invalidTerminalStatus") + object["rawStatus"] = .string(rawStatus) + object["turnId"] = .string(turnID) + 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 + } + return .object(object) + } +} + +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 diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 8f773c1..38ef39d 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1342,6 +1342,45 @@ struct CodexReviewStoreCommandTests { #expect(read.core.lifecycle.status == .failed) #expect(read.core.lifecycle.errorMessage == "Review was interrupted by the backend.") + #expect(read.core.lifecycle.failure == .interruptedByBackend(message: nil)) + } + } + + @Test func typedTerminalFailureSurvivesStoreCommit() async throws { + let backend = FakeCodexReviewBackend() + let store = CodexReviewStore.makeTestingStore( + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-1" }) + ) + let failure = ReviewBackendFailure.invalidTerminalStatus( + rawStatus: "future-terminal", + turnID: "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)) + let read = try await result + + #expect(read.core.lifecycle.status == .failed) + #expect(read.core.lifecycle.failure == failure) + #expect( + read.core.lifecycle.errorMessage + == "Review ended with invalid terminal status future-terminal." + ) } } diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 6969725..1ce991f 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -440,12 +440,45 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.failed(.message("Backend failed"))) + await backend.yield( + .failed( + .turnFailed( + .init( + message: "Backend failed", + code: .httpConnectionFailed(status: 503), + additionalDetails: "Retry later" + ) + ) + ) + ) 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" + ) } } From d3402498771a36aec4c585ff0e0e40e7da03213e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:03:23 +0900 Subject: [PATCH 11/62] test(app-server): use current-v2 item fixtures --- .../AppServerClientTests.swift | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 0f561de..0c82ea7 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -135,7 +135,7 @@ struct AppServerClientTests { #expect(try await iterator.next() == .completed(finalReview: "No issues found.")) } - @Test func backendDoesNotPromoteThreadScopedFinalAnswerDeltaWithoutTurnID() async throws { + @Test func backendRejectsItemDeltaWithoutRequiredTurnID() 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") @@ -154,19 +154,18 @@ struct AppServerClientTests { phase: "final_answer" ) ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "completed") - ) - ) - #expect( try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect( - try await iterator.next() - == .failed(.missingReviewOutput(turnID: "turn-1"))) + do { + _ = try await iterator.next() + Issue.record("Expected the malformed current-v2 notification to terminate the event stream.") + } catch { + #expect( + error.localizedDescription.contains( + "Current-v2 item notification is missing required turnId." + ) + ) + } } @Test func backendDoesNotPromoteThreadlessAgentMessageToInlineReview() async throws { @@ -347,6 +346,14 @@ struct AppServerClientTests { try await runtime.transport.enqueueReviewStart(turnID: "turn-2", reviewThreadID: "review-thread-2") let secondAttempt = try await backend.startReview(makeReviewStart(runID: "run-2", sessionID: "session-2")) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: TestItemNotification( + threadID: "review-thread-1", + turnID: "turn-1", + item: .init(type: "agentMessage", id: "msg-1", text: "") + ) + ) try await runtime.transport.emitServerNotification( method: "item/agentMessage/delta", params: TestDeltaNotification( @@ -356,6 +363,14 @@ struct AppServerClientTests { delta: "first" ) ) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: TestItemNotification( + threadID: "review-thread-2", + turnID: "turn-2", + item: .init(type: "agentMessage", id: "msg-1", text: "") + ) + ) try await runtime.transport.emitServerNotification( method: "item/agentMessage/delta", params: TestDeltaNotification( @@ -421,6 +436,20 @@ struct AppServerClientTests { model: "gpt-5" )) + try await runtime.transport.emitServerNotification( + method: "item/started", + params: TestItemNotification( + threadID: "review-thread", + turnID: "turn-1", + item: .init( + type: "commandExecution", + id: "cmd-1", + command: "swift test", + aggregatedOutput: "", + status: "inProgress" + ) + ) + ) try await runtime.transport.emitServerNotification( method: "item/commandExecution/outputDelta", params: TestDeltaNotification( @@ -886,6 +915,8 @@ private struct TestItem: Encodable, Sendable { var type: String var id: String var review: String? + var text: String? + var phase: String? var command: String? var cwd: String? var aggregatedOutput: String? @@ -896,6 +927,8 @@ private struct TestItem: Encodable, Sendable { type: String, id: String, review: String? = nil, + text: String? = nil, + phase: String? = nil, command: String? = nil, cwd: String? = nil, aggregatedOutput: String? = nil, @@ -905,6 +938,8 @@ private struct TestItem: Encodable, Sendable { self.type = type self.id = id self.review = review + self.text = text + self.phase = phase self.command = command self.cwd = cwd self.aggregatedOutput = aggregatedOutput From ebbcedb6e2c0157414894bef37f3451f77c893a7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:39:57 +0900 Subject: [PATCH 12/62] docs(architecture): preserve deprecation notice schema --- Docs/rearchitecture-2026-07-10.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index dadcd88..cb2a7b3 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -920,9 +920,8 @@ public struct CodexRetryDiagnostic: Equatable, Sendable { } public struct CodexDeprecationNotice: Equatable, Sendable { - public var feature: String { get } - public var message: String { get } - public var replacement: String? { get } + public var summary: String { get } + public var details: String? { get } } public struct CodexConnectionEvents: AsyncSequence, Sendable { @@ -975,6 +974,7 @@ public struct CodexReviewSession { } ``` +- `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`はsupervisor actorが最初に受理したterminal causeを固定する。explicit close requestがEOF/process exit signalより先なら`.closedByCaller`、transport failureが先なら`.transportFailure`、process waiter exitが先なら`.processExited`である。後着causeはdiagnosticへ残すだけでpublic terminalを上書きしない。hubはbounded diagnosticsとこのcompact terminalだけを保持し、late connection subscriberへterminal 1件をreplayしてfinishする。 - `close()` は `ConnectionSupervisor` のshared close taskへ収束し、§5.3の固定順序を最後まで完了する。 From 55cecc6bf9f651f589e943bd9aa72b2a44e307fa Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:46:12 +0900 Subject: [PATCH 13/62] docs(architecture): clarify connection ownership --- Docs/rearchitecture-2026-07-10.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index cb2a7b3..64ba85e 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -205,7 +205,7 @@ CodexAppServer facade | `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` | bounded diagnostics、subscribers、first terminal reason | routerにhistoryを持たせず、late subscriberへcompact terminal 1件だけreplay。explicit close/EOF/process exit raceをactor順で一度だけ裁定 | +| `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、mutation strategy | query/mutationの意味論を call site に漏らさない | @@ -262,7 +262,7 @@ CodexAppServer facade | `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` | package actor(同一isolation) | Taskを作らない | bounded diagnostic subscriber、terminal replay、first-terminal-wins reasonを所有 | +| `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` | checked `Sendable` class。mutable contextは`@MainActor` | Taskを作らない | main contextをeager生成し、transaction deliveryはMainActor structured callをcallerがawait。lazy pending replay Taskを削除 | @@ -844,11 +844,11 @@ package extension JSONRPC { 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しない。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を通る。 +`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へ保つ。live stdout readerはsingle producerなので17th send completionより先にpipeから18th frameをreadせず、test concurrent producersも各send completionをawaitする。transportが保持するpayload数は常に17以下である。 +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が一致する。 @@ -976,7 +976,7 @@ public struct CodexReviewSession { - `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`はsupervisor actorが最初に受理したterminal causeを固定する。explicit close requestがEOF/process exit signalより先なら`.closedByCaller`、transport failureが先なら`.transportFailure`、process waiter exitが先なら`.processExited`である。後着causeはdiagnosticへ残すだけでpublic terminalを上書きしない。hubはbounded diagnosticsとこのcompact terminalだけを保持し、late connection subscriberへterminal 1件をreplayしてfinishする。 +- `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する。 From 1ab844d8d9bc4f39275a5f6c0cf4c9e80b127ad9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:56:20 +0900 Subject: [PATCH 14/62] docs(architecture): pin transport lifecycle contracts --- Docs/rearchitecture-2026-07-10.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index 64ba85e..68a5693 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -852,6 +852,10 @@ open中にglobal overflowへaccept済みの1 frameはcloseと競合してもreje 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しない。 From ea5bb9f468ddd07cc94e45399e33b8ba762d045c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:19:16 +0900 Subject: [PATCH 15/62] docs(architecture): define bounded thread event ownership --- Docs/rearchitecture-2026-07-10.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index 68a5693..f80b917 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -183,7 +183,7 @@ CodexAppServer facade -> JSONRPCTransport (framing/I/O/process only) -> AppServerNotificationDecoder (single exhaustive current-v2 switch) -> NotificationRouter (typed routing only; Task を所有しない) - -> TurnReplayStore / AccountEventHub / ServerRequestRegistry + -> TurnReplayStore / ThreadEventHub / AccountEventHub / ServerRequestRegistry ``` ## 4. Target owner map @@ -199,8 +199,9 @@ CodexAppServer facade | `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、subscriber | run Task と無期限 terminal historyを所有しない | +| `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 | @@ -259,6 +260,7 @@ CodexAppServer facade | `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を所有 | @@ -989,10 +991,13 @@ public struct CodexReviewSession { - 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`だけを提供する。 - 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はnon-droppable control eventで、必要ならfinal snapshot→terminal/closedの順にdeliveryする。同一turnの同値terminalはexactly-once、異なるterminalはcontract violationである。generation commitは未消費の旧generation queueをnew compact generationで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送信前に generation leaseを登録し、request中に届くearly eventを同じleaseへroutingする。routerのglobal append-only historyから後で拾う経路は残さない。 +- 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する。 @@ -4145,7 +4150,7 @@ pinned upstreamは `review_rollout_assistant` が同一review-exit operationのc 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、lane cleanup、typed server requests、notification disposition、diagnostics、item/account reducers、strict current-v2 decoder。各変更でTesting transportも同じseamへcompile migrationする。 +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を削除する。 From 37bb94e78cb6abc87b74031a4e5b590c49215ae4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:15:26 +0900 Subject: [PATCH 16/62] docs(architecture): bound thread terminal delivery --- Docs/rearchitecture-2026-07-10.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index f80b917..b1c0591 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -992,7 +992,7 @@ public struct CodexReviewSession { - 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`だけを提供する。 - 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はnon-droppable control eventで、必要ならfinal snapshot→terminal/closedの順にdeliveryする。同一turnの同値terminalはexactly-once、異なるterminalはcontract violationである。generation commitは未消費の旧generation queueをnew compact generationでsupersedeし、generation resetまたはconnection closeで旧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へ移さない。 From c4005c9c162d3434af190c96cc299ac273fe60c2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:50:54 +0900 Subject: [PATCH 17/62] refactor(app-server): align consumers with bounded events --- .../LiveCodexReviewStoreBackend.swift | 6 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 164 +----------------- .../AppServerClientTests.swift | 14 +- .../CodexReviewHostTests.swift | 13 +- .../ReviewMonitorChatLogTesting.swift | 18 +- .../ReviewMonitorCodexChatDetailTests.swift | 8 +- 6 files changed, 50 insertions(+), 173 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 1639960..48aa7cf 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -467,7 +467,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.appServerModelContainer = modelContainer self.appServerBackend = backend appServerLifecycleHandler?(modelContainer) - observeAuthNotifications(appServer: appServer, backend: backend, store: store) + await observeAuthNotifications(appServer: appServer, backend: backend, store: store) if let mcpHTTPServerFactory { let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider( modelContext: modelContainer.mainContext @@ -1166,13 +1166,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer: CodexAppServer, backend: AppServerCodexReviewBackend, store: CodexReviewStore - ) { + ) async { authNotificationTask?.cancel() + let stream = await appServer.accountEvents() 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( diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 71fba5d..70e2fdb 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -159,17 +159,17 @@ final class ReviewMonitorPreviewAppServerRuntime { guard let fixture = fixturesByChatID[chatID] else { return } - guard let item = await upsertStoredItem( + guard await upsertStoredItem( id: id, kind: kind, content: content, in: fixture - ) else { + ) != nil else { return } start() enqueueNotification { [weak self] in - await self?.emitItem(item, for: fixture) + await self?.refreshStoredChat(fixture.chatID) } } @@ -385,7 +385,7 @@ final class ReviewMonitorPreviewAppServerRuntime { runtime: runtime ) case .update, .complete: - await emitItem(storedItem, for: fixture) + await refreshStoredChat(fixture.chatID) } } catch { } @@ -549,29 +549,14 @@ final class ReviewMonitorPreviewAppServerRuntime { } } - private func emitItem( - _ storedItem: ReviewMonitorPreviewStoredThreadItem, - for fixture: ReviewMonitorPreviewChatLogFixture - ) async { + private func refreshStoredChat(_ chatID: CodexThreadID) async { do { try await ensureStarted() - guard let runtime else { + guard let container 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 - ) - ) - ) + let context = container.mainContext + try await context.refresh(context.model(for: chatID)) } catch { } } @@ -739,31 +724,6 @@ private struct PreviewTurnInterruptParams: Decodable, Sendable { } } -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 @@ -799,6 +759,7 @@ private struct PreviewTurnCompletedParams: Encodable, Sendable { var id: String var status: String? var completedAt: Int? + var items: [String] = [] } } @@ -833,113 +794,6 @@ private struct PreviewTurnDeltaParams: Encodable, Sendable { } } -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) - ) -} - -private func previewNotificationItemType( - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content -) -> String { - switch content { - case .diagnostic: - "diagnostic" - default: - kind.rawValue - } -} - -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 - case .reasoning(let reasoning): - reasoning.text - case .toolCall(let toolCall): - toolCall.result ?? toolCall.error ?? toolCall.name - case .contextCompaction(let text): - text - case .command, .fileChange, .unknown: - nil - } -} - -private func previewNotificationPhase(_ content: CodexThreadItem.Content) -> String? { - if case .message(let message) = content { - return message.phase?.rawValue - } - return nil -} - -private func previewNotificationCommand(_ content: CodexThreadItem.Content) -> String? { - if case .command(let command) = content { - return command.command - } - return nil -} - -private func previewNotificationCWD(_ content: CodexThreadItem.Content) -> String? { - if case .command(let command) = content { - return command.cwd - } - 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 - } -} - -private func previewNotificationExitCode(_ content: CodexThreadItem.Content) -> Int? { - if case .command(let command) = content { - return command.exitCode - } - 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 func previewNotificationPath(_ content: CodexThreadItem.Content) -> String? { - if case .fileChange(let fileChange) = content { - return fileChange.path - } - return nil -} - private extension ReviewMonitorPreviewChatLogFixture { var threadSnapshot: CodexThreadSnapshot { threadSnapshot(initialThreadSnapshot) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 0c82ea7..4e46ede 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -162,7 +162,7 @@ struct AppServerClientTests { } catch { #expect( error.localizedDescription.contains( - "Current-v2 item notification is missing required turnId." + "Current-v2 notification is missing required field turnId." ) ) } @@ -842,13 +842,13 @@ private struct TestTurnNotification: Encodable, Sendable { private struct TestTurn: Encodable, Sendable { var id: String var status: String - var items: [TestItem]? + var items: [TestItem] var error: TestTurnError? init( id: String, status: String, - items: [TestItem]? = nil, + items: [TestItem] = [], error: TestTurnError? = nil ) { self.id = id @@ -903,11 +903,15 @@ private struct TestItemNotification: Encodable, Sendable { var threadID: String var turnID: String var item: TestItem + var startedAtMs: Int64 = 0 + var completedAtMs: Int64 = 0 enum CodingKeys: String, CodingKey { case threadID = "threadId" case turnID = "turnId" case item + case startedAtMs + case completedAtMs } } @@ -918,6 +922,7 @@ private struct TestItem: Encodable, Sendable { var text: String? var phase: String? var command: String? + var commandActions: [String] var cwd: String? var aggregatedOutput: String? var exitCode: Int? @@ -941,7 +946,8 @@ private struct TestItem: Encodable, Sendable { self.text = text self.phase = phase self.command = command - self.cwd = cwd + self.commandActions = [] + self.cwd = type == "commandExecution" ? (cwd ?? "/tmp/project") : cwd self.aggregatedOutput = aggregatedOutput self.exitCode = exitCode self.status = status diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 8c9fd4a..11d53f8 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1196,28 +1196,27 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await transport.waitForNotificationStreamCount(1) - await waitUntil { + #expect(await waitUntil(timeout: .seconds(1)) { 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" + planType: "pro" )) ) try await transport.emitServerNotification( method: "account/rateLimits/updated", params: TestRateLimitsUpdatedNotification(rateLimits: .init( - limitID: "codex_bengalfox", + limitID: "codex", primary: .init(usedPercent: 11, windowDurationMins: 300) )) ) - await waitUntil { + #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]) } diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index f1c7eac..942544d 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -687,7 +687,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 { diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 6959744..a1e60e5 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -414,7 +414,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(initialSnapshot.log.contains("Legacy fallback") == false) try await runtime.transport.emitServerNotification( - method: "item/updated", + method: "item/completed", params: ThreadItemParams( threadID: "review-thread", turnID: "turn-1", @@ -486,7 +486,7 @@ struct ReviewMonitorCodexChatDetailTests { let reloadCount = transport.logReloadCountForTesting try await runtime.transport.emitServerNotification( - method: "item/updated", + method: "item/completed", params: ThreadItemParams( threadID: "review-thread", turnID: "turn-1", @@ -1227,7 +1227,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) try await runtime.transport.emitServerNotification( - method: "item/updated", + method: "item/completed", params: ThreadItemParams( threadID: "chat-thread", turnID: "turn-1", @@ -1284,11 +1284,13 @@ private struct ThreadItemParams: Encodable, Sendable { var threadID: String var turnID: String var item: Item + var completedAtMs: Int64 = 0 enum CodingKeys: String, CodingKey { case threadID = "threadId" case turnID = "turnId" case item + case completedAtMs } struct Item: Encodable, Sendable { From a001ff337b0c962e2a536559c9dda877211c03e9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:56:01 +0900 Subject: [PATCH 18/62] refactor(auth): adopt stock login lifecycle --- .../AppServerCodexReviewBackend.swift | 62 -- Sources/CodexReviewHost/CodexReviewHost.swift | 303 ------ .../CodexReviewNativeAuthentication.swift | 194 ---- .../LiveCodexReviewStoreBackend.swift | 927 ++++++++++-------- .../CodexReviewKit/CodexReviewBackend.swift | 3 - Sources/CodexReviewKit/CodexReviewTypes.swift | 37 - .../Model/CodexReviewAuthModel.swift | 52 +- .../Store/CodexReviewStore.swift | 20 +- .../Store/CodexReviewStoreBackend.swift | 4 +- .../PreviewCodexReviewStoreBackend.swift | 8 +- Sources/CodexReviewTesting/TestSupport.swift | 40 +- .../ReviewMonitorAddAccountAction.swift | 21 +- Sources/ReviewUI/SignIn/SignInView.swift | 22 +- .../CodexReviewHostTests.swift | 635 +++--------- .../CodexReviewStoreCommandTests.swift | 16 +- Tests/ReviewUITests/ReviewUITests.swift | 6 +- .../CodexReviewMonitorApp.swift | 40 +- .../CodexReviewMonitorCITests.swift | 49 +- 18 files changed, 766 insertions(+), 1673 deletions(-) delete mode 100644 Sources/CodexReviewHost/CodexReviewHost.swift delete mode 100644 Sources/CodexReviewHost/CodexReviewNativeAuthentication.swift diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 6fb46f8..fc2a9fe 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -87,35 +87,6 @@ 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() @@ -766,36 +737,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/CodexReviewHost.swift b/Sources/CodexReviewHost/CodexReviewHost.swift deleted file mode 100644 index 8f9a748..0000000 --- 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 52f0a54..0000000 --- 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/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 48aa7cf..b4ca7aa 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -8,11 +8,22 @@ 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) async throws -> Void public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexModelContainer?) -> Void private let defaultExternalURLOpener: ExternalURLOpener = { url in - _ = NSWorkspace.shared.open(url) + try await withCheckedThrowingContinuation { continuation in + NSWorkspace.shared.open( + url, + configuration: .init() + ) { _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } } private actor RuntimeShutdownCleanupRace { @@ -68,16 +79,6 @@ private func runRuntimeShutdownCleanup( 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 - } -} - package struct CodexReviewMCPPortOwner: Equatable, Sendable { package var processIdentifier: Int32 package var command: String? @@ -109,14 +110,10 @@ extension CodexReviewMCPHTTPServer: CodexReviewMCPHTTPServing {} 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,9 +121,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, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, shutdownCleanupTimeout: Duration = .seconds(2), @@ -138,8 +133,6 @@ public extension CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, @@ -154,9 +147,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, @@ -174,8 +165,6 @@ public extension CodexReviewStore { backend: LiveCodexReviewStoreBackend( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, @@ -215,26 +204,19 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 loginSession: LoginSession? private var authNotificationTask: Task? - private var loginNotificationTask: 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 accountRegistry: AccountRegistryStore + private let accountRuntimeTransitionCoordinator: AccountRuntimeTransitionCoordinator + private let registryLoadFailure: CodexReviewAuthenticationFailure? private let shutdownCleanupTimeout: Duration private let appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? private weak var attachedStore: CodexReviewStore? @@ -242,8 +224,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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( @@ -265,13 +245,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { runtimePreferences: runtimePreferences, environment: environment ) + accountRegistry = AccountRegistryStore(codexHomeURL: codexHomeURL) + accountRuntimeTransitionCoordinator = AccountRuntimeTransitionCoordinator() 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 @@ -281,9 +261,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory( codexExecutablePath: runtimePreferences.codexExecutablePath ) - let registry = CodexReviewAccountRegistry.load( - codexHomeURL: codexHomeURL - ) + let registry: (accounts: [CodexReviewAccount], activeAccountKey: String?) + do { + registry = try CodexReviewAccountRegistry.load(codexHomeURL: codexHomeURL) + registryLoadFailure = nil + } catch let failure as CodexReviewAuthenticationFailure { + registry = ([], nil) + registryLoadFailure = failure + } catch { + registry = ([], nil) + registryLoadFailure = .persistenceInconsistent(message: error.localizedDescription) + } seed = CodexReviewStoreSeed( shouldAutoStartEmbeddedServer: true, initialAccounts: registry.accounts, @@ -443,6 +431,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { func start(store: CodexReviewStore, forceRestartIfNeeded: Bool) async { logger.info("Starting review runtime; forceRestartIfNeeded=\(forceRestartIfNeeded, privacy: .public)") + if let registryLoadFailure { + store.auth.updatePhase(.failed(registryLoadFailure)) + store.transitionToFailed(registryLoadFailure.localizedDescription) + return + } if appServerBackend != nil, forceRestartIfNeeded == false { logger.info("Review runtime already has an app-server backend") store.transitionToRunning(serverURL: await mcpHTTPServer?.url) @@ -551,8 +544,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let appServerBackend = appServerBackend let mcpHTTPServer = mcpHTTPServer let hasRuntimeState = appServer != nil || appServerBackend != nil || mcpHTTPServer != nil - let loginCleanup = takeLoginRuntimeForCleanup() - guard hasRuntimeState || loginCleanup.isEmpty == false else { + let loginSession = self.loginSession + self.loginSession = nil + guard hasRuntimeState || loginSession != nil else { return } logger.info("Stopping review runtime") @@ -572,7 +566,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { authNotificationTask = nil await mcpHTTPServer?.stop() self.appServerBackend = nil - await cleanupLoginRuntime(loginCleanup) + await terminateLoginSession(loginSession) await appServer?.close() logger.info("Review runtime stopped") } @@ -649,17 +643,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let snapshot = try await appServerBackend.readAuth() applyAuthSnapshot(snapshot, to: auth) } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } } - func signIn(auth: CodexReviewAuthModel) async { - await startLogin(auth: auth, activation: .activateAuthenticatedAccount) + func signIn(auth: CodexReviewAuthModel) async throws { + try await beginStockLogin(auth: auth, activation: .activateAuthenticatedAccount) } - func addAccount(auth: CodexReviewAuthModel) async { + func addAccount(auth: CodexReviewAuthModel) async throws { let activeAccountKey = auth.persistedActiveAccountKey ?? auth.selectedAccount?.accountKey - await startLogin( + try await beginStockLogin( auth: auth, activation: activeAccountKey != nil ? .preserveActiveAccount(activeAccountKey) @@ -668,40 +662,55 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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 { + guard let session = loginSession else { if auth.selectedAccount == nil { auth.updatePhase(.signedOut) } - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) return } + let resultTask = session.takeResultTask() + resultTask?.cancel() do { - try await loginBackend.cancelLogin(loginChallenge) - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) + let outcome = try await session.handle.cancel() + await resultTask?.value + if case .authenticationCommittedNeedsConnectionReconciliation(let reason) = outcome, + case .activateAuthenticatedAccount = session.activation + { + await reconcilePrimaryAuthentication( + session: session, + reason: reason, + auth: auth + ) + return + } + loginSession = nil + switch outcome { + case .cancelled: + auth.updatePhase(.signedOut) + case .failed(let message): + updateAuthenticationFailure( + message ?? "Authentication cancellation failed.", + auth: auth, + activation: session.activation + ) + case .succeeded, .authenticationCommittedNeedsConnectionReconciliation: + updateAuthenticationFailure( + "Authentication could not be safely committed after cancellation.", + auth: auth, + activation: session.activation + ) + } } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + loginSession = nil + auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) + await resultTask?.value + await closeLoginRuntimeIfNeeded(session) + await releaseLoginMutationIfNeeded(session) } func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { + try await withAccountMutation { guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { return } @@ -722,9 +731,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 { + try await withAccountMutation { let removedActiveAccount = auth.selectedAccount?.accountKey == accountKey || auth.persistedActiveAccountKey == accountKey if removedActiveAccount, let appServerBackend { @@ -760,6 +771,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await stop(store: attachedStore) await start(store: attachedStore, forceRestartIfNeeded: true) } + } } func reorderPersistedAccount( @@ -767,6 +779,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { accountKey: String, toIndex: Int ) async throws { + try await withAccountMutation { var accounts = auth.persistedAccounts guard let sourceIndex = accounts.firstIndex(where: { $0.accountKey == accountKey }) else { return @@ -783,9 +796,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { codexHomeURL: codexHomeURL ) auth.applyPersistedAccountStates(accounts.map(savedAccountPayload(from:))) + } } func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { + try await withAccountMutation { guard let account = auth.selectedAccount else { auth.updatePhase(.signedOut) auth.selectPersistedAccount(nil) @@ -816,6 +831,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await stop(store: attachedStore) await start(store: attachedStore, forceRestartIfNeeded: true) } + } } func refreshAccountRateLimits(auth: CodexReviewAuthModel, accountKey: String) async { @@ -829,167 +845,272 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { false } - private func startLogin(auth: CodexReviewAuthModel, activation: LoginActivation) async { - var isolatedLoginAppServer: CodexAppServer? - var isolatedLoginCodexHomeURL: URL? + private func withAccountMutation( + _ operation: () async throws -> T + ) async throws -> T { + try await accountRuntimeTransitionCoordinator.perform { + let lease = try await accountRegistry.beginAccountMutation() + do { + let result = try await operation() + await accountRegistry.finishMutation(lease) + return result + } catch { + await accountRegistry.finishMutation(lease) + throw error + } + } + } + + private func beginStockLogin( + auth: CodexReviewAuthModel, + activation: LoginActivation + ) async throws { + guard loginSession == nil else { + throw CodexReviewAuthenticationFailure.alreadyInProgress + } + let mutationLease = try await accountRegistry.beginAuthenticationMutation() + var runtimeRequiringCleanup: LoginRuntime? 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 - ) - 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 + runtimeRequiringCleanup = runtime + let handle = try await runtime.appServer.loginChatGPT( + accountReadinessTimeout: .seconds(10) + ) + let session = LoginSession( + handle: handle, + runtime: runtime, + activation: activation, + mutationLease: mutationLease + ) + loginSession = session auth.updatePhase(.signingIn(.init( title: "Sign in to Codex", - detail: challenge.signInDetail(nativeAuthentication: usesNativeAuthentication), - browserURL: challenge.verificationURL?.absoluteString, - userCode: challenge.userCode + detail: "Continue signing in with your browser.", + browserURL: handle.authenticationURL.absoluteString, + userCode: nil ))) - guard let nativeAuthenticationConfiguration, challenge.verificationURL != nil else { - if let verificationURL = challenge.verificationURL { - externalURLOpener(verificationURL) + do { + try await externalURLOpener(handle.authenticationURL) + } catch { + loginSession = nil + _ = try? await handle.cancel() + throw CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) + } + let sessionID = session.id + session.installResultTask(Task { @MainActor [weak self, weak auth] in + guard let self, let auth else { + return } - return + do { + let outcome = try await handle.result() + await self.finishStockLogin( + sessionID: sessionID, + outcome: outcome, + auth: auth + ) + } catch is CancellationError { + } catch { + await self.finishStockLogin( + sessionID: sessionID, + failure: error, + auth: auth + ) + } + }) + runtimeRequiringCleanup = nil + } catch { + await closeLoginRuntime(runtimeRequiringCleanup) + await accountRegistry.finishMutation(mutationLease) + let failure = (error as? CodexReviewAuthenticationFailure) + ?? .runtime(message: error.localizedDescription) + updateAuthenticationFailure( + failure.localizedDescription, + auth: auth, + activation: activation + ) + if case .preserveActiveAccount = activation { + throw failure } - 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 + } + } + + private func finishStockLogin( + sessionID: UUID, + outcome: CodexLoginOutcome, + auth: CodexReviewAuthModel + ) async { + guard let session = loginSession, session.id == sessionID else { + return + } + switch outcome { + case .succeeded: + do { + let snapshot = try await session.runtime.backend.readAuth() + let isolatedRateLimits: CodexRateLimits? + if session.runtime.usesPrimaryRuntime == false { + isolatedRateLimits = try? await session.runtime.backend.readRateLimits() + await session.runtime.appServer.close() + session.markOwnedRuntimeClosed() + } else { + isolatedRateLimits = nil + } + let account = applyAuthSnapshot( + snapshot, + to: auth, + activation: session.activation, + authSourceCodexHomeURL: session.runtime.codexHomeURL + ) + if session.runtime.usesPrimaryRuntime == false { + if let account, let isolatedRateLimits { + applyRateLimits(isolatedRateLimits, to: account) + try? CodexReviewAccountRegistry.updateCachedRateLimits( + from: account, + codexHomeURL: codexHomeURL + ) + } + try? FileManager.default.removeItem(at: session.runtime.codexHomeURL) + } else { + await refreshSelectedAccountRateLimits(auth: auth) + } + } catch { updateAuthenticationFailure( - "Authentication callback is misconfigured.", + error.localizedDescription, auth: auth, - activation: activation + activation: session.activation ) - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) + } + session.markResultCompleted() + guard session.isReadyForCleanup else { return } - let session = try await webAuthenticationSessionFactory( - authURL, - callbackScheme, - nativeAuthenticationConfiguration.browserSessionPolicy, - nativeAuthenticationConfiguration.presentationAnchorProvider + case .failed(let message): + updateAuthenticationFailure( + message ?? "Authentication failed.", + auth: auth, + activation: session.activation ) - activeAuthenticationSession = session - authenticationTask = Task { @MainActor [weak self, weak auth] in - guard let self, let auth else { - return - } - await self.monitorAuthenticationSession( - challenge: challenge, + case .cancelled: + auth.updatePhase(.signedOut) + case .authenticationCommittedNeedsConnectionReconciliation(let reason): + if case .activateAuthenticatedAccount = session.activation { + await reconcilePrimaryAuthentication( session: session, - completesLoginThroughCallback: nativeCallbackScheme != nil, + reason: reason, auth: auth ) + return } - } 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, + "Authentication requires runtime reconciliation: \(String(describing: reason))", auth: auth, - activation: activation + activation: session.activation ) } + await finishLoginSessionCleanup(session) } - private func monitorAuthenticationSession( - challenge: CodexReviewBackendModel.Login.Challenge, - session: any CodexReviewNativeAuthentication.WebSession, - completesLoginThroughCallback: Bool, + private func reconcilePrimaryAuthentication( + session: LoginSession, + reason: CodexLoginReconciliationReason, auth: CodexReviewAuthModel ) async { + guard loginSession === session else { + return + } + loginSession = nil + session.takeResultTask() do { - let callbackURL = try await session.waitForCallbackURL() - guard loginChallenge?.id == challenge.id else { - return - } - guard completesLoginThroughCallback else { - logger.info("Authentication session completed; waiting for app-server login completion notification") - return - } - guard let loginBackend else { - throw CodexReviewAPI.Error.io("Authentication runtime is not available.") - } - try await loginBackend.completeLogin(challenge, callbackURL: callbackURL) - guard loginChallenge?.id == challenge.id else { - return + try await accountRuntimeTransitionCoordinator.perform { + guard let store = attachedStore else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication committed, but the review store is unavailable for reconciliation." + ) + } + await stop(store: store) + await start(store: store, forceRestartIfNeeded: true) + guard let backend = appServerBackend else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication committed, but the replacement runtime failed to start." + ) + } + let snapshot = try await backend.readAuth() + guard let activeAccountID = snapshot.activeAccountID?.rawValue, + let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), + backendAccount.kind == .chatGPT + else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "Authentication committed, but the replacement runtime did not confirm a ChatGPT account." + ) + } + _ = applyAuthSnapshot(snapshot, to: auth) } - 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 let failure as CodexReviewAuthenticationFailure { + auth.updatePhase(.failed(failure)) + logger.error( + "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(failure.localizedDescription, privacy: .public)" + ) } catch { - guard loginChallenge?.id == challenge.id else { - return - } - 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 + let failure = CodexReviewAuthenticationFailure.runtime(message: error.localizedDescription) + auth.updatePhase(.failed(failure)) + logger.error( + "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(error.localizedDescription, privacy: .public)" ) } + await releaseLoginMutationIfNeeded(session) + } + + private func finishStockLogin( + sessionID: UUID, + failure: any Error, + auth: CodexReviewAuthModel + ) async { + guard let session = loginSession, session.id == sessionID else { + return + } + updateAuthenticationFailure( + failure.localizedDescription, + auth: auth, + activation: session.activation + ) + await finishLoginSessionCleanup(session) + } + + private func finishLoginSessionCleanup(_ session: LoginSession) async { + guard loginSession === session else { + return + } + loginSession = nil + session.takeResultTask() + await closeLoginRuntimeIfNeeded(session) + await releaseLoginMutationIfNeeded(session) + } + + 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 session.ownedRuntimeNeedsClose else { + return + } + await closeLoginRuntime(session.runtime) + session.markOwnedRuntimeClosed() + } + + private func releaseLoginMutationIfNeeded(_ session: LoginSession) async { + guard let lease = session.takeMutationLeaseForRelease() else { + return + } + await accountRegistry.finishMutation(lease) } private func updateAuthenticationFailure( @@ -999,9 +1120,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) { switch activation { case .activateAuthenticatedAccount: - auth.updatePhase(.failed(message: message)) + auth.updatePhase(.failed(.login(message: message))) case .preserveActiveAccount: - auth.recordAuthenticationFailure(message: message) + auth.updatePhase(.failed(.login(message: message))) } } @@ -1021,6 +1142,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let temporaryCodexHomeURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true) let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) + 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,37 +1158,6 @@ 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 { guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") @@ -1201,8 +1298,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { _ error: any Error, store: CodexReviewStore ) async { - let loginCleanup = takeLoginRuntimeForCleanup() - guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginCleanup.isEmpty == false else { + let loginSession = self.loginSession + self.loginSession = nil + guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginSession != nil else { return } let message = "Review runtime stopped unexpectedly: \(error.localizedDescription)" @@ -1224,7 +1322,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { authNotificationTask = nil store.transitionToFailed(message) await failedMCPHTTPServer?.stop() - await cleanupLoginRuntime(loginCleanup) + await terminateLoginSession(loginSession) await failedAppServer?.close() } @@ -1234,10 +1332,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth: CodexReviewAuthModel ) async { switch event { - case .loginCompleted(let completion): - await handleLoginCompletedNotification(completion, backend: backend, auth: auth) case .accountUpdated: - await handleAccountUpdatedNotification(backend: backend, auth: auth) + if let loginSession { + loginSession.markAccountUpdateConsumed() + if loginSession.isReadyForCleanup { + await finishLoginSessionCleanup(loginSession) + } + return + } + await refreshAuthAfterAccountNotification(backend: backend, auth: auth) case .rateLimitsUpdated(let rateLimits): await applyRateLimitsUpdatedNotification(rateLimits, auth: auth) case .malformed(let method, let message): @@ -1247,142 +1350,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func observeLoginNotifications( - appServer: CodexAppServer, - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) { - loginNotificationTask?.cancel() - loginNotificationTask = Task { @MainActor [weak self, weak auth] in - guard let self, let auth else { - return - } - let stream = await appServer.accountEvents() - do { - for try await event in stream { - await self.handleLoginRuntimeNotification(event, backend: backend, auth: auth) - } - } catch is CancellationError { - } catch { - logger.error("Login notification stream ended: \(error.localizedDescription, privacy: .public)") - } - } - } - - 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 handleLoginCompletedNotification( - _ completion: CodexLoginCompletion, - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - guard completion.loginID?.rawValue == nil || completion.loginID?.rawValue == loginChallenge?.id else { - return - } - 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 - } - isWaitingForLoginAccountUpdate = true - logger.info("ChatGPT login completed; waiting for account update notification") - } - - private func handleAccountUpdatedNotification( - backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel - ) async { - guard isWaitingForLoginAccountUpdate else { - await refreshAuthAfterAccountNotification(backend: backend, auth: auth) - return - } - 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 - ) - } - self.loginBackend = nil - self.loginAppServer = nil - self.loginCodexHomeURL = nil - isWaitingForLoginAccountUpdate = false - loginNotificationTask?.cancel() - loginNotificationTask = nil - await closeIsolatedLoginRuntime(appServer: loginAppServer, codexHomeURL: loginCodexHomeURL) - } - private func refreshAuthAfterAccountNotification( backend: AppServerCodexReviewBackend, auth: CodexReviewAuthModel @@ -1391,7 +1358,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { applyAuthSnapshot(try await backend.readAuth(), to: auth) await refreshSelectedAccountRateLimits(auth: auth) } catch { - auth.updatePhase(.failed(message: error.localizedDescription)) + auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } } @@ -1556,30 +1523,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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) + private func terminateLoginSession(_ session: LoginSession?) async { + guard let session else { + return + } + let resultTask = session.takeResultTask() + resultTask?.cancel() + do { + _ = try await session.handle.cancel() + } catch { + logger.error("Failed to cancel login during teardown: \(error.localizedDescription, privacy: .public)") + } + await resultTask?.value + await closeLoginRuntimeIfNeeded(session) + await releaseLoginMutationIfNeeded(session) } private func applyRateLimits( @@ -1665,12 +1622,6 @@ 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 @@ -1688,6 +1639,135 @@ private struct LoginRuntime: Sendable { var usesPrimaryRuntime: Bool } +private actor AccountRegistryStore { + struct MutationLease: Hashable, Sendable { + fileprivate let id: UUID + } + + private enum MutationKind { + case authentication + case account + } + + let codexHomeURL: URL + private var activeMutation: (lease: MutationLease, kind: MutationKind)? + + init(codexHomeURL: URL) { + self.codexHomeURL = codexHomeURL + } + + func beginAuthenticationMutation() throws -> MutationLease { + if let activeMutation { + switch activeMutation.kind { + case .authentication: + throw CodexReviewAuthenticationFailure.alreadyInProgress + case .account: + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + } + return installMutation(kind: .authentication) + } + + func beginAccountMutation() throws -> MutationLease { + guard activeMutation == nil else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + return installMutation(kind: .account) + } + + func finishMutation(_ lease: MutationLease) { + precondition(activeMutation?.lease == lease, "Only the active account mutation owner can release its lease.") + activeMutation = nil + } + + private func installMutation(kind: MutationKind) -> MutationLease { + let lease = MutationLease(id: UUID()) + activeMutation = (lease, kind) + return lease + } +} + +@MainActor +private final class AccountRuntimeTransitionCoordinator { + private var isTransitioning = false + + func perform(_ operation: () async throws -> T) async throws -> T { + guard isTransitioning == false else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } + isTransitioning = true + defer { isTransitioning = false } + return try await operation() + } +} + +@MainActor +private final class LoginSession { + let id = UUID() + let handle: CodexLoginHandle + let runtime: LoginRuntime + let activation: LoginActivation + let mutationLease: AccountRegistryStore.MutationLease + private var resultTask: Task? + private var didCompleteResult = false + private var didConsumeAccountUpdate: Bool + private var didCloseOwnedRuntime = false + private var didReleaseMutationLease = false + + init( + handle: CodexLoginHandle, + runtime: LoginRuntime, + activation: LoginActivation, + mutationLease: AccountRegistryStore.MutationLease + ) { + self.handle = handle + self.runtime = runtime + self.activation = activation + self.mutationLease = mutationLease + self.didConsumeAccountUpdate = runtime.usesPrimaryRuntime == false + } + + func installResultTask(_ task: Task) { + precondition(resultTask == nil, "A login session owns exactly one result task.") + resultTask = task + } + + func markResultCompleted() { + didCompleteResult = true + } + + func markAccountUpdateConsumed() { + didConsumeAccountUpdate = true + } + + var isReadyForCleanup: Bool { + didCompleteResult && didConsumeAccountUpdate + } + + var ownedRuntimeNeedsClose: Bool { + runtime.usesPrimaryRuntime == false && didCloseOwnedRuntime == false + } + + func markOwnedRuntimeClosed() { + precondition(runtime.usesPrimaryRuntime == false) + didCloseOwnedRuntime = true + } + + func takeMutationLeaseForRelease() -> AccountRegistryStore.MutationLease? { + guard didReleaseMutationLease == false else { + return nil + } + didReleaseMutationLease = true + return mutationLease + } + + @discardableResult + func takeResultTask() -> Task? { + defer { resultTask = nil } + return resultTask + } +} + private enum LoginActivation: Equatable, Sendable { case activateAuthenticatedAccount case preserveActiveAccount(String?) @@ -1831,8 +1911,8 @@ private enum CodexReviewAccountRegistry { } } - static func load(codexHomeURL: URL) -> (accounts: [CodexReviewAccount], activeAccountKey: String?) { - let registry = loadRegistry(codexHomeURL: codexHomeURL) + static func load(codexHomeURL: URL) throws -> (accounts: [CodexReviewAccount], activeAccountKey: String?) { + let registry = try loadRegistry(codexHomeURL: codexHomeURL) let accounts = registry.accounts.compactMap(makeAccount(from:)) let activeAccountKey = registry.activeAccountKey .map(CodexReviewAccount.normalizedEmail) @@ -1848,7 +1928,7 @@ private enum CodexReviewAccountRegistry { activeAccountKey: String?, codexHomeURL: URL ) throws { - let existing = loadRegistry(codexHomeURL: codexHomeURL) + let existing = try loadRegistry(codexHomeURL: codexHomeURL) let existingByAccountKey = Dictionary(uniqueKeysWithValues: existing.accounts.compactMap { entry in normalizedAccountKey(from: entry).map { ($0, entry) } }) @@ -1917,7 +1997,7 @@ private enum CodexReviewAccountRegistry { from account: CodexReviewAccount, codexHomeURL: URL ) throws { - var registry = loadRegistry(codexHomeURL: codexHomeURL) + var registry = try loadRegistry(codexHomeURL: codexHomeURL) guard let index = registry.accounts.firstIndex(where: { normalizedAccountKey(from: $0) == account.accountKey }) else { @@ -2025,14 +2105,19 @@ private enum CodexReviewAccountRegistry { return account } - private static func loadRegistry(codexHomeURL: URL) -> Registry { + private static func loadRegistry(codexHomeURL: URL) throws -> Registry { let url = registryURL(codexHomeURL: codexHomeURL) - guard let data = try? Data(contentsOf: url), - let registry = try? JSONDecoder().decode(Registry.self, from: data) - else { + guard FileManager.default.fileExists(atPath: url.path) else { return .init(activeAccountKey: nil, accounts: []) } - return registry + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(Registry.self, from: data) + } catch { + throw CodexReviewAuthenticationFailure.persistenceInconsistent( + message: "The account registry is inconsistent: \(error.localizedDescription)" + ) + } } private static func saveRegistry( diff --git a/Sources/CodexReviewKit/CodexReviewBackend.swift b/Sources/CodexReviewKit/CodexReviewBackend.swift index afca4ce..2bb4dcb 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -6,9 +6,6 @@ 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 diff --git a/Sources/CodexReviewKit/CodexReviewTypes.swift b/Sources/CodexReviewKit/CodexReviewTypes.swift index 156f828..977c94c 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,42 +157,6 @@ 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 diff --git a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift index 506f73f..44bc18f 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift @@ -1,6 +1,40 @@ import Foundation import Observation +public 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 { + public 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 { @@ -74,7 +108,7 @@ public final class CodexReviewAuthModel { public enum Phase: Sendable, Equatable { case signedOut case signingIn(Progress) - case failed(message: String) + case failed(CodexReviewAuthenticationFailure) } public package(set) var phase: Phase = .signedOut @@ -83,8 +117,6 @@ public final class CodexReviewAuthModel { package private(set) var detachedAccount: CodexReviewAccount? public 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? @@ -115,10 +147,10 @@ public final class CodexReviewAuthModel { } public var errorMessage: String? { - guard case .failed(let message) = phase else { + 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/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index b2c5d15..9867fb4 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -147,19 +147,19 @@ public final class CodexReviewStore { await backend.refreshAuth(auth: auth) } - public func signIn() async { - await backend.signIn(auth: auth) + public func signIn() async throws { + try await backend.signIn(auth: auth) } - public func addAccount() async { - await backend.addAccount(auth: auth) + public func addAccount() async throws { + try await backend.addAccount(auth: auth) } public func cancelAuthentication() async { await backend.cancelAuthentication(auth: auth) } - package func performPrimaryAuthenticationAction() async { + package func performPrimaryAuthenticationAction() async throws { if auth.isAuthenticating { await cancelAuthentication() return @@ -175,7 +175,7 @@ public final class CodexReviewStore { else { return } - await signIn() + try await signIn() } public func logout() async { @@ -187,7 +187,7 @@ 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))) } } } @@ -253,12 +253,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, diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index 3358a33..474a3ed 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -34,8 +34,8 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { func stop(store: CodexReviewStore) 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 diff --git a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift index b5f8946..cb29421 100644 --- a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift @@ -66,12 +66,12 @@ 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 { diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index a1e913d..44734d2 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -117,8 +117,6 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { 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) @@ -374,17 +372,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 { @@ -669,28 +656,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 { diff --git a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift index 9aa1463..53914be 100644 --- a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift +++ b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift @@ -5,24 +5,15 @@ import CodexReviewKit 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 - { + do { + try await store.addAccount() + } catch let failure as CodexReviewAuthenticationFailure { 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 + message: failure.localizedDescription ) + } catch { + preconditionFailure("Unexpected authentication error: \(error)") } } } diff --git a/Sources/ReviewUI/SignIn/SignInView.swift b/Sources/ReviewUI/SignIn/SignInView.swift index caa2d2b..c54a40c 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,13 @@ struct SignInView: View { Button(role: store.auth.isAuthenticating ? .cancel : .confirm) { Task { @MainActor in - await store.performPrimaryAuthenticationAction() + do { + try await store.performPrimaryAuthenticationAction() + } catch let failure as CodexReviewAuthenticationFailure { + authenticationFailureMessage = failure.localizedDescription + } catch { + preconditionFailure("Unexpected authentication error: \(error)") + } } } label: { LabeledContent { @@ -43,6 +50,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/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 11d53f8..b39e3f2 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -1,6 +1,5 @@ import Foundation import AppKit -import AuthenticationServices import Testing import CodexAppServerKit import CodexAppServerKitTesting @@ -17,9 +16,7 @@ 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 }, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, shutdownCleanupTimeout: Duration = .seconds(2), @@ -31,8 +28,6 @@ private extension CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, @@ -53,9 +48,7 @@ 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 }, mcpHTTPServerFactory: (@MainActor @Sendable ( CodexReviewStore, CodexReviewMCPHTTPServer.Configuration, @@ -72,8 +65,6 @@ private extension CodexReviewStore { makeLiveStoreForTesting( environment: environment, runtimePreferences: runtimePreferences, - nativeAuthenticationConfiguration: nativeAuthenticationConfiguration, - webAuthenticationSessionFactory: webAuthenticationSessionFactory, externalURLOpener: externalURLOpener, mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, @@ -96,67 +87,6 @@ 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) - ) - } - 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() - - #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") - } - @Test func runtimePreferencesNormalizeInvalidValues() { let preferences = CodexReviewRuntime.Preferences( codexHomePath: " ", @@ -265,7 +195,6 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], runtimePreferences: .init(codexHomePath: configuredCodexHomeURL.path), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == configuredCodexHomeURL) return transport @@ -291,7 +220,6 @@ struct CodexReviewHostTests { var observedLifecycleStates: [Bool] = [] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { _, configuration, _ in NoopMCPHTTPServer(endpoint: configuration.url()) }, @@ -330,7 +258,6 @@ struct CodexReviewHostTests { mcpPort: 54321, mcpPath: "custom-mcp" ), - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { store, configuration, logProjectionProvider in capturedConfiguration = configuration capturedLogProjectionProvider = logProjectionProvider @@ -365,7 +292,6 @@ struct CodexReviewHostTests { 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()) }, @@ -408,7 +334,6 @@ struct CodexReviewHostTests { 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()) }, @@ -458,7 +383,6 @@ struct CodexReviewHostTests { ) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: FakeCodexAppServerTransport() ) @@ -475,6 +399,37 @@ struct CodexReviewHostTests { #expect(providerAccount.capabilities.supportsRateLimitRefresh == false) } + @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 liveStoreSkipsRateLimitRefreshForUnsupportedActiveAccount() async throws { let transport = FakeCodexAppServerTransport() try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") @@ -489,7 +444,6 @@ struct CodexReviewHostTests { try await transport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": try temporaryHome().path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: transport ) @@ -507,7 +461,7 @@ struct CodexReviewHostTests { ]) } - @Test func liveStoreCompletesNativeLoginFromAuthenticationSessionAndAccountNotifications() async throws { + @Test func liveStoreCompletesStockLoginAfterAccountReadiness() 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") @@ -519,112 +473,14 @@ struct CodexReviewHostTests { try await transport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") + authURL: "https://example.com/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 transport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 20, windowDurationMins: 300) - )), - for: "account/rateLimits/read" - ) - let sessions = FakeWebAuthenticationSessions() - let externalURLOpener = FakeExternalURLOpener() - 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 - ) - - 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")!) - await transport.waitForRequestCount(6) - let completeRequest = try #require(await transport.recordedRequests().first { - $0.method == "account/login/complete" - }) - 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", - ]) - 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 + AppServerAPI.Account.Read.Response( + account: .init(email: "new@example.com", planType: "plus") ), - 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( @@ -634,50 +490,47 @@ struct CodexReviewHostTests { )), for: "account/rateLimits/read" ) - let sessions = FakeWebAuthenticationSessions() let externalURLOpener = FakeExternalURLOpener() 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 ) await store.start(forceRestartIfNeeded: true) await transport.waitForNotificationStreamCount(1) - await store.addAccount() + try 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")!) - try await transport.emitServerNotification( + #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(method: "account/login/start").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 await transport.emitServerNotificationJSON( method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-1", success: true) + json: #"{"loginId":"login-1","success":true,"error":null}"# ) - try await transport.emitServerNotification( + try await transport.emitServerNotificationJSON( method: "account/updated", - params: EmptyResponse() + json: #"{"authMode":"chatgpt","planType":"plus"}"# ) - await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" } + #expect(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.accountKey == "new@example.com" + }) await transport.waitForRequestCount(7) - let methods = await transport.recordedRequests().map(\.method) - #expect(methods == [ + #expect(await transport.recordedRequests().map(\.method) == [ "initialize", "account/read", "config/read", @@ -689,7 +542,7 @@ struct CodexReviewHostTests { await store.stop() } - @Test func liveStoreCancelsLoginWhenNativeSessionFactoryFails() async throws { + @Test func liveStoreCancelsLoginWhenOpeningAuthenticationURLFails() 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") @@ -701,8 +554,7 @@ struct CodexReviewHostTests { try await transport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") + authURL: "https://example.com/auth" ), for: "account/login/start" ) @@ -710,30 +562,24 @@ struct CodexReviewHostTests { AppServerAPI.Account.Login.Cancel.Response(), for: "account/login/cancel" ) - var didCreateNativeSession = false - let externalURLOpener = FakeExternalURLOpener() + let externalURLOpener = FakeExternalURLOpener( + failure: CodexReviewAPI.Error.io("Authentication presentation failed.") + ) 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, transport: transport ) await store.start(forceRestartIfNeeded: true) - await store.addAccount() + try await store.addAccount() await transport.waitForRequestCount(6) - #expect(didCreateNativeSession) - #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") - #expect(externalURLOpener.openedURLs == []) + #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(\.method) == [ "initialize", "account/read", @@ -774,15 +620,10 @@ struct CodexReviewHostTests { try await authTransport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") + authURL: "https://example.com/auth" ), for: "account/login/start" ) - try await authTransport.enqueue( - AppServerAPI.Account.Login.Complete.Response(), - for: "account/login/complete" - ) try await authTransport.enqueue( AppServerAPI.Account.Read.Response( account: .init(email: "new@example.com", planType: "plus") @@ -816,16 +657,9 @@ struct CodexReviewHostTests { var nonPrimaryTransports = [authTransport, refreshTransport] var nonPrimaryRuntimeIndex = 0 var refreshCodexHomeURL: URL? - let sessions = FakeWebAuthenticationSessions() let externalURLOpener = FakeExternalURLOpener() 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 { @@ -847,44 +681,28 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) #expect(store.auth.selectedAccount?.accountKey == "active@example.com") - await store.addAccount() + try await store.addAccount() await authTransport.waitForNotificationStreamCount(1) await authTransport.waitForRequestCount(2) - #expect(sessions.createdSessionCount == 1) - #expect(externalURLOpener.openedURLs == []) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) 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" - }) - let completeParams = try JSONDecoder().decode( - AppServerAPI.Account.Login.Complete.Params.self, - from: completeRequest.params - ) - #expect(completeParams.loginID == "login-2") - #expect(completeParams.callbackURL == "lynnpd.CodexReviewMonitor.auth://callback?code=abc") + #expect(loginParams.type == "chatgpt") try await authTransport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification(loginID: "login-2", success: true) ) - try await authTransport.emitServerNotification( + try await authTransport.emitServerNotificationJSON( method: "account/updated", - params: EmptyResponse() + json: #"{"authMode":"chatgpt","planType":"plus"}"# ) - await waitUntil { + #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") @@ -894,10 +712,14 @@ struct CodexReviewHostTests { #expect(await authTransport.recordedRequests().map(\.method) == [ "initialize", "account/login/start", - "account/login/complete", "account/read", "account/rateLimits/read", ]) + 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) @@ -965,7 +787,6 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in if codexHomeURL == mainCodexHomeURL { return mainTransport @@ -1006,15 +827,10 @@ struct CodexReviewHostTests { try await transport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-new", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") + authURL: "https://example.com/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" @@ -1027,15 +843,8 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) let externalURLOpener = FakeExternalURLOpener() - let sessions = FakeWebAuthenticationSessions() 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) @@ -1047,21 +856,16 @@ struct CodexReviewHostTests { #expect(store.auth.selectedAccount == nil) #expect(store.auth.persistedAccounts.map(\.accountKey) == ["existing@example.com"]) - await store.addAccount() + try 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) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) try await transport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification(loginID: "login-new", success: true) ) - try await transport.emitServerNotification( + try await transport.emitServerNotificationJSON( method: "account/updated", - params: EmptyResponse() + json: #"{"authMode":"chatgpt","planType":"plus"}"# ) await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" @@ -1080,13 +884,12 @@ struct CodexReviewHostTests { "config/read", "model/list", "account/login/start", - "account/login/complete", "account/read", "account/rateLimits/read", ]) } - @Test func liveStoreAddAccountCancelsLoginWhenNativeSessionFactoryFails() async throws { + @Test func liveStoreAddAccountCancelsLoginWhenOpeningURLFails() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) try writeRegistry( @@ -1117,8 +920,7 @@ struct CodexReviewHostTests { try await loginTransport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: .init(callbackURLScheme: "lynnpd.CodexReviewMonitor.auth") + authURL: "https://example.com/auth" ), for: "account/login/start" ) @@ -1127,17 +929,11 @@ struct CodexReviewHostTests { for: "account/login/cancel" ) var isolatedCodexHomeURL: URL? - let externalURLOpener = FakeExternalURLOpener() + let externalURLOpener = FakeExternalURLOpener( + failure: CodexReviewAPI.Error.io("Authentication presentation failed.") + ) 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 { @@ -1150,15 +946,21 @@ struct CodexReviewHostTests { ) await store.start(forceRestartIfNeeded: true) - let previousFailureCount = store.auth.authenticationFailureCount - await store.addAccount() + 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(store.auth.authenticationFailureCount == previousFailureCount + 1) - #expect(failedMessage(from: store.auth.phase) == "Authentication presentation failed.") + #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 == []) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) await store.stop() #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) #expect(await loginTransport.recordedRequests().map(\.method) == [ @@ -1190,7 +992,6 @@ struct CodexReviewHostTests { ) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": try temporaryHome().path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: transport ) @@ -1275,7 +1076,6 @@ struct CodexReviewHostTests { var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() @@ -1346,7 +1146,6 @@ struct CodexReviewHostTests { var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() @@ -1407,7 +1206,6 @@ struct CodexReviewHostTests { ) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: transport ) @@ -1440,7 +1238,6 @@ struct CodexReviewHostTests { try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, mcpHTTPServerFactory: { store, _, _ in CodexReviewMCPHTTPServer( adapter: CodexReviewMCPServer(store: store), @@ -1500,7 +1297,6 @@ struct CodexReviewHostTests { try await transport.enqueue(AppServerAPI.Review.Start.Response(turnID: "turn-1"), for: "review/start") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, shutdownCleanupTimeout: .milliseconds(20), transport: transport ) @@ -1551,7 +1347,6 @@ struct CodexReviewHostTests { try await transport.enqueue(EmptyResponse(), for: "thread/delete") let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, shutdownCleanupTimeout: .seconds(1), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }), @@ -1600,7 +1395,6 @@ struct CodexReviewHostTests { 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 ) @@ -1646,22 +1440,18 @@ struct CodexReviewHostTests { try await loginTransport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-1", - authURL: "https://example.com/auth", - nativeWebAuthentication: nil + authURL: "https://example.com/auth" ), for: "account/login/start" ) + try await loginTransport.enqueue( + AppServerAPI.Account.Login.Cancel.Response(), + for: "account/login/cancel" + ) 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 { @@ -1675,12 +1465,11 @@ 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 { @@ -1738,7 +1527,6 @@ struct CodexReviewHostTests { var mainTransports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transportFactory: { codexHomeURL in #expect(codexHomeURL == mainCodexHomeURL) return mainTransports.removeFirst() @@ -1766,12 +1554,6 @@ struct CodexReviewHostTests { 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 isolatedCodexHomeURL = codexHomeURL try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) @@ -1779,7 +1561,12 @@ struct CodexReviewHostTests { } ) - await store.addAccount() + do { + try await store.addAccount() + Issue.record("Expected unavailable main runtime to propagate to the add-account command.") + } catch let failure as CodexReviewAuthenticationFailure { + #expect(failure == .runtime(message: "Review runtime is not running.")) + } let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) #expect(failedMessage(from: store.auth.phase) == "Review runtime is not running.") @@ -1815,12 +1602,6 @@ struct CodexReviewHostTests { 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 @@ -1832,7 +1613,17 @@ 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( @@ -1866,22 +1657,14 @@ struct CodexReviewHostTests { try await loginTransport.enqueue( AppServerAPI.Account.Login.Response.chatgpt( loginID: "login-2", - authURL: "https://example.com/auth", - nativeWebAuthentication: nil + authURL: "https://example.com/auth" ), for: "account/login/start" ) 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 { @@ -1894,11 +1677,9 @@ 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 == []) + #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) try await loginTransport.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification( @@ -1928,7 +1709,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)]) @@ -1954,7 +1734,6 @@ struct CodexReviewHostTests { try FileManager.default.createDirectory(at: dotDotDirectoryURL, withIntermediateDirectories: true) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - webAuthenticationSessionFactory: FakeWebAuthenticationSessions().makeSession, transport: FakeCodexAppServerTransport() ) store.auth.applyPersistedAccountStates([ @@ -2244,52 +2023,21 @@ private enum AppServerAPI { 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 + codexStreamlinedLogin: Bool = true ) { self.type = type self.apiKey = apiKey self.codexStreamlinedLogin = codexStreamlinedLogin - self.nativeWebAuthentication = nativeWebAuthentication - } - } - - struct NativeWebAuthentication: Codable, Equatable, Sendable { - var callbackURLScheme: String - - enum CodingKeys: String, CodingKey { - case callbackURLScheme = "callbackUrlScheme" - } - } - - enum Complete { - struct Params: Codable, Equatable, Sendable { - var loginID: String - var callbackURL: String - - enum CodingKeys: String, CodingKey { - case loginID = "loginId" - case callbackURL = "callbackUrl" - } - } - - struct Response: Codable, Equatable, Sendable { - init() {} } } enum Response: Codable, Equatable, Sendable { case apiKey - case chatgpt( - loginID: String, - authURL: String, - nativeWebAuthentication: NativeWebAuthentication? - ) + case chatgpt(loginID: String, authURL: String) case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String) case chatgptAuthTokens @@ -2297,7 +2045,6 @@ private enum AppServerAPI { case type case loginID = "loginId" case authURL = "authUrl" - case nativeWebAuthentication case verificationURL = "verificationUrl" case userCode } @@ -2310,11 +2057,7 @@ private enum AppServerAPI { 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 - ) + authURL: try container.decode(String.self, forKey: .authURL) ) case "chatgptDeviceCode": self = .chatgptDeviceCode( @@ -2338,11 +2081,10 @@ private enum AppServerAPI { switch self { case .apiKey: try container.encode("apiKey", forKey: .type) - case .chatgpt(let loginID, let authURL, let nativeWebAuthentication): + case .chatgpt(let loginID, let authURL): 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) @@ -2364,102 +2106,21 @@ private enum AppServerAPI { } } -@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 - let waiters = waiters - self.waiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume(returning: session) - } - return session - } - - func waitForSession() async -> FakeWebAuthenticationSession { - if let session { - return session - } - return await withCheckedContinuation { continuation in - if let session { - continuation.resume(returning: session) - } else { - waiters.append(continuation) - } - } - } -} - @MainActor private final class FakeExternalURLOpener { private(set) var openedURLs: [URL] = [] + private let failure: (any Error)? - func open(_ url: URL) { - openedURLs.append(url) - } -} - -@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) - } - - func closeFromAuthenticationWindow() async { - resume(throwing: CodexReviewNativeAuthenticationError.cancelled) - } - - func complete(with url: URL) { - callbackContinuation?.resume(returning: url) - callbackContinuation = nil + init(failure: (any Error)? = nil) { + self.failure = failure } - func waitUntilWaitingForCallback() async { - if callbackContinuation != nil { - return - } - await withCheckedContinuation { continuation in - if callbackContinuation != nil { - continuation.resume() - } else { - callbackWaiters.append(continuation) - } + func open(_ url: URL) async throws { + openedURLs.append(url) + if let failure { + throw failure } } - - private func resume(throwing error: Error) { - callbackContinuation?.resume(throwing: error) - callbackContinuation = nil - } } private func temporaryHome() throws -> URL { @@ -2572,10 +2233,10 @@ 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 { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 38ef39d..ea5a9cd 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1623,7 +1623,7 @@ struct CodexReviewStoreCommandTests { let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - await withStoreCommandTestCleanup(backend: backend, store: store) { + try await withStoreCommandTestCleanup(backend: backend, store: store) { await store.closeSession("session-1") await #expect(throws: (any Error).self) { @@ -1789,26 +1789,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 - }) } } } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index a5bf311..946097b 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -5763,7 +5763,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 } @@ -5808,10 +5808,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 } } diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index a9aaf36..2eaee98 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -203,11 +203,6 @@ final class ReviewMonitorLifecycleController { } } -@MainActor -private final class ReviewMonitorPresentationAnchorSource { - weak var window: NSWindow? -} - @MainActor struct ReviewMonitorAppDependencies { let store: CodexReviewStore @@ -225,23 +220,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 +239,7 @@ struct ReviewMonitorAppComposition { var makeSettingsWindowController: () -> NSWindowController init( - makeDependencies: @escaping ( - ReviewMonitorLaunchContext, - @escaping PresentationAnchorProvider - ) -> ReviewMonitorAppDependencies, + makeDependencies: @escaping (ReviewMonitorLaunchContext) -> ReviewMonitorAppDependencies, makeLifecycleController: @escaping ( any ReviewMonitorLifecycleStore, ReviewMonitorLaunchContext @@ -284,17 +267,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 +285,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) @@ -345,16 +322,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 +335,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 a6aadbe..ae79f04 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -88,7 +88,7 @@ struct CodexReviewMonitorCITests { let recorder = WindowControllerFactoryRecorder() let settingsWindowController = CountingWindowController() let composition = ReviewMonitorAppComposition( - makeDependencies: { context, _ in + makeDependencies: { context in capturedContext = context return ReviewMonitorAppDependencies(store: expectedStore) }, @@ -146,7 +146,7 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in + makeDependencies: { _ in ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) }, makeWindowController: { _, _ in @@ -193,7 +193,7 @@ struct CodexReviewMonitorCITests { @Test func appDelegateShowsInjectedSettingsWindowController() { let settingsWindowController = CountingWindowController() let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in + makeDependencies: { _ in ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) }, makeWindowController: { _, _ in @@ -225,7 +225,7 @@ 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() @@ -238,17 +238,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,7 +251,7 @@ 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() @@ -271,10 +264,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( @@ -327,12 +317,10 @@ struct CodexReviewMonitorCITests { ) let expectedStore = CodexReviewStore.makePreviewStore() 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,25 +329,12 @@ 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() { @@ -368,7 +343,7 @@ struct CodexReviewMonitorCITests { var capturedLifecycleHandler: CodexReviewAppServerLifecycleHandler? let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, - makeLiveStore: { _, _, appServerLifecycleHandler in + makeLiveStore: { _, appServerLifecycleHandler in capturedLifecycleHandler = appServerLifecycleHandler return expectedStore } @@ -379,7 +354,7 @@ struct CodexReviewMonitorCITests { launchMode: .application ) - let dependencies = composition.makeDependencies(context) { nil } + let dependencies = composition.makeDependencies(context) let store = dependencies.store #expect(store === expectedStore) @@ -618,7 +593,7 @@ struct CodexReviewMonitorCITests { } let composition = ReviewMonitorAppComposition( - makeDependencies: { _, _ in + makeDependencies: { _ in ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) }, makeWindowController: { _, _ in From f3aec28c8e753e32bfe8b9239f86aed5f269a93e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:09:38 +0900 Subject: [PATCH 19/62] refactor(auth): centralize account registry ownership --- .../LiveCodexReviewStoreBackend.swift | 273 ++++++++++++------ 1 file changed, 178 insertions(+), 95 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index b4ca7aa..645106e 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -261,20 +261,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory( codexExecutablePath: runtimePreferences.codexExecutablePath ) - let registry: (accounts: [CodexReviewAccount], activeAccountKey: String?) + let registry: AccountRegistryStore.Snapshot do { - registry = try CodexReviewAccountRegistry.load(codexHomeURL: codexHomeURL) + registry = try AccountRegistryStore.loadInitialSnapshot(codexHomeURL: codexHomeURL) registryLoadFailure = nil } catch let failure as CodexReviewAuthenticationFailure { - registry = ([], nil) + registry = .init(accounts: [], activeAccountKey: nil) registryLoadFailure = failure } catch { - registry = ([], nil) + 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 ) } @@ -476,7 +476,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) let authSnapshot = try await backend.readAuth() - applyAuthSnapshot(authSnapshot, to: store.auth) + await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) await refreshSelectedAccountRateLimits(auth: store.auth) logger.info("Review runtime started") } catch { @@ -641,7 +641,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } let snapshot = try await appServerBackend.readAuth() - applyAuthSnapshot(snapshot, to: auth) + await applyAuthSnapshot(snapshot, to: auth) } catch { auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } @@ -714,10 +714,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { return } - try CodexReviewAccountRegistry.activateAccount( + try await accountRegistry.activateAccount( accountKey, - accounts: auth.persistedAccounts, - codexHomeURL: codexHomeURL + accounts: auth.persistedAccounts.map(savedAccountPayload(from:)) ) auth.applyPersistedAccountStates( auth.persistedAccounts.map(savedAccountPayload(from:)), @@ -745,17 +744,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 accountRegistry.saveAccounts( + remaining.map(savedAccountPayload(from:)), + activeAccountKey: activeAccountKey ) + try await accountRegistry.removeSavedAccountDirectory(accountKey: accountKey) if removedActiveAccount { - try? CodexReviewAccountRegistry.removeSharedAuth(codexHomeURL: codexHomeURL) + try? await accountRegistry.removeSharedAuth() } auth.applyPersistedAccountStates( remaining.map(savedAccountPayload(from:)), @@ -790,10 +785,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let account = accounts.remove(at: sourceIndex) accounts.insert(account, at: destinationIndex) - try CodexReviewAccountRegistry.saveAccounts( - accounts, - activeAccountKey: auth.persistedActiveAccountKey, - codexHomeURL: codexHomeURL + try await accountRegistry.saveAccounts( + accounts.map(savedAccountPayload(from:)), + activeAccountKey: auth.persistedActiveAccountKey ) auth.applyPersistedAccountStates(accounts.map(savedAccountPayload(from:))) } @@ -814,16 +808,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { _ = try await appServerBackend.logout(.init(account.accountKey)) } 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 await accountRegistry.saveAccounts( + remaining.map(savedAccountPayload(from:)), + activeAccountKey: nil ) - try? CodexReviewAccountRegistry.removeSharedAuth(codexHomeURL: codexHomeURL) + try await accountRegistry.removeSavedAccountDirectory(accountKey: account.accountKey) + try? await accountRegistry.removeSharedAuth() auth.updatePhase(.signedOut) auth.selectPersistedAccount(nil) auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:)), activeAccountKey: nil) @@ -954,7 +944,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } else { isolatedRateLimits = nil } - let account = applyAuthSnapshot( + let account = await applyAuthSnapshot( snapshot, to: auth, activation: session.activation, @@ -963,9 +953,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { if session.runtime.usesPrimaryRuntime == false { if let account, let isolatedRateLimits { applyRateLimits(isolatedRateLimits, to: account) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) ) } try? FileManager.default.removeItem(at: session.runtime.codexHomeURL) @@ -1042,7 +1031,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { message: "Authentication committed, but the replacement runtime did not confirm a ChatGPT account." ) } - _ = applyAuthSnapshot(snapshot, to: auth) + _ = await applyAuthSnapshotSerialized(snapshot, to: auth) } } catch let failure as CodexReviewAuthenticationFailure { auth.updatePhase(.failed(failure)) @@ -1202,7 +1191,24 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, authSourceCodexHomeURL: URL? = nil - ) -> CodexReviewAccount? { + ) async -> CodexReviewAccount? { + await accountRuntimeTransitionCoordinator.performInternal { + await self.applyAuthSnapshotSerialized( + snapshot, + to: auth, + activation: activation, + authSourceCodexHomeURL: authSourceCodexHomeURL + ) + } + } + + @discardableResult + private func applyAuthSnapshotSerialized( + _ snapshot: CodexReviewBackendModel.Auth.Snapshot, + to auth: CodexReviewAuthModel, + activation: LoginActivation = .activateAuthenticatedAccount, + authSourceCodexHomeURL: URL? = nil + ) async -> CodexReviewAccount? { guard let activeAccountID = snapshot.activeAccountID?.rawValue, let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), let account = Self.monitorAccount(from: backendAccount) @@ -1230,23 +1236,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { authenticatedAccountKey: account.accountKey, persistedAccounts: persistedAccounts ) - try? CodexReviewAccountRegistry.saveAccounts( - persistedAccounts, - activeAccountKey: activeAccountKey, - codexHomeURL: codexHomeURL + try? await accountRegistry.saveAccounts( + persistedAccounts.map(savedAccountPayload(from:)), + activeAccountKey: activeAccountKey ) switch activation { case .activateAuthenticatedAccount: - try? CodexReviewAccountRegistry.saveSharedAuth( - for: account, - codexHomeURL: codexHomeURL - ) + try? await accountRegistry.saveSharedAuth(for: savedAccountPayload(from: account)) case .preserveActiveAccount: if let authSourceCodexHomeURL { - try? CodexReviewAccountRegistry.saveSharedAuth( + try? await accountRegistry.saveSharedAuth( from: authSourceCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL + for: savedAccountPayload(from: account) ) } } @@ -1355,7 +1356,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth: CodexReviewAuthModel ) async { do { - applyAuthSnapshot(try await backend.readAuth(), to: auth) + await applyAuthSnapshot(try await backend.readAuth(), to: auth) await refreshSelectedAccountRateLimits(auth: auth) } catch { auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) @@ -1373,9 +1374,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } applyRateLimits(rateLimits, to: selectedAccount) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: selectedAccount, - codexHomeURL: codexHomeURL + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: selectedAccount) ) } @@ -1396,7 +1396,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let didRefresh = await refreshRateLimits(for: account, using: appServerBackend, source: "active-runtime") if didRefresh { - persistRefreshedSharedAuth( + await persistRefreshedSharedAuth( from: codexHomeURL, for: account ) @@ -1407,18 +1407,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let temporaryCodexHomeURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-rate-limits-\(UUID().uuidString)", isDirectory: true) 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 + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) ) return } @@ -1426,10 +1424,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let didRefresh = await refreshRateLimits(for: account, using: runtime.backend, source: "saved-auth-isolated-runtime") do { if didRefresh { - try CodexReviewAccountRegistry.saveSharedAuth( + try await accountRegistry.saveSharedAuth( from: temporaryCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL + for: savedAccountPayload(from: account) ) } } catch { @@ -1440,9 +1437,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } catch { try? FileManager.default.removeItem(at: temporaryCodexHomeURL) account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) ) } } @@ -1464,16 +1460,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let response = try await backend.readRateLimits() applyRateLimits(response, to: account) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) ) return true } catch { recordRateLimitRefreshFailure(error, account: account) - try? CodexReviewAccountRegistry.updateCachedRateLimits( - from: account, - codexHomeURL: codexHomeURL + try? await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) ) return false } @@ -1559,14 +1553,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func persistRefreshedSharedAuth( from sourceCodexHomeURL: URL?, for account: CodexReviewAccount - ) { + ) async { guard let sourceCodexHomeURL else { return } - try? CodexReviewAccountRegistry.saveSharedAuth( + try? await accountRegistry.saveSharedAuth( from: sourceCodexHomeURL, - for: account, - codexHomeURL: codexHomeURL + for: savedAccountPayload(from: account) ) } @@ -1640,6 +1633,11 @@ private struct LoginRuntime: Sendable { } private actor AccountRegistryStore { + struct Snapshot: Sendable { + let accounts: [CodexSavedAccountPayload] + let activeAccountKey: String? + } + struct MutationLease: Hashable, Sendable { fileprivate let id: UUID } @@ -1656,6 +1654,70 @@ private actor AccountRegistryStore { self.codexHomeURL = codexHomeURL } + nonisolated static func loadInitialSnapshot(codexHomeURL: URL) throws -> Snapshot { + try Disk.load(codexHomeURL: codexHomeURL) + } + + func load() throws -> Snapshot { + try Disk.load(codexHomeURL: codexHomeURL) + } + + func saveAccounts( + _ accounts: [CodexSavedAccountPayload], + activeAccountKey: String? + ) throws { + try Disk.saveAccounts( + accounts, + activeAccountKey: activeAccountKey, + codexHomeURL: codexHomeURL + ) + } + + func activateAccount( + _ accountKey: String, + accounts: [CodexSavedAccountPayload] + ) throws { + try Disk.activateAccount( + accountKey, + accounts: accounts, + codexHomeURL: codexHomeURL + ) + } + + func updateCachedRateLimits(from account: CodexSavedAccountPayload) throws { + try Disk.updateCachedRateLimits(from: account, codexHomeURL: codexHomeURL) + } + + func saveSharedAuth( + from sourceCodexHomeURL: URL? = nil, + for account: CodexSavedAccountPayload + ) throws { + try Disk.saveSharedAuth( + from: sourceCodexHomeURL ?? codexHomeURL, + for: account, + codexHomeURL: codexHomeURL + ) + } + + func removeSharedAuth() throws { + try Disk.removeSharedAuth(codexHomeURL: codexHomeURL) + } + + func removeSavedAccountDirectory(accountKey: String) throws { + try Disk.removeSavedAccountDirectory( + accountKey: accountKey, + codexHomeURL: codexHomeURL + ) + } + + func copySavedAuth(accountKey: String, to destinationCodexHomeURL: URL) throws -> Bool { + try Disk.copySavedAuth( + accountKey: accountKey, + from: codexHomeURL, + to: destinationCodexHomeURL + ) + } + func beginAuthenticationMutation() throws -> MutationLease { if let activeMutation { switch activeMutation.kind { @@ -1690,15 +1752,37 @@ private actor AccountRegistryStore { @MainActor private final class AccountRuntimeTransitionCoordinator { private var isTransitioning = false + private var transitionCompletionWaiters: [CheckedContinuation] = [] func perform(_ operation: () async throws -> T) async throws -> T { guard isTransitioning == false else { throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } isTransitioning = true - defer { isTransitioning = false } + defer { finishTransition() } return try await operation() } + + func performInternal(_ operation: () async -> T) async -> T { + while isTransitioning { + await withCheckedContinuation { continuation in + transitionCompletionWaiters.append(continuation) + } + } + isTransitioning = true + defer { finishTransition() } + return await operation() + } + + private func finishTransition() { + precondition(isTransitioning) + isTransitioning = false + let waiters = transitionCompletionWaiters + transitionCompletionWaiters.removeAll(keepingCapacity: true) + for waiter in waiters { + waiter.resume() + } + } } @MainActor @@ -1791,8 +1875,8 @@ private enum LoginActivation: Equatable, Sendable { private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async throws -> AppServerRuntime -@MainActor -private enum CodexReviewAccountRegistry { +private extension AccountRegistryStore { +enum Disk { private struct Registry: Codable { var activeAccountKey: String? var accounts: [Entry] @@ -1911,20 +1995,20 @@ private enum CodexReviewAccountRegistry { } } - static func load(codexHomeURL: URL) throws -> (accounts: [CodexReviewAccount], activeAccountKey: String?) { + static func load(codexHomeURL: URL) throws -> AccountRegistryStore.Snapshot { let registry = try loadRegistry(codexHomeURL: codexHomeURL) - let accounts = registry.accounts.compactMap(makeAccount(from:)) + 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 (accounts, activeAccountKey) + return .init(accounts: accounts, activeAccountKey: activeAccountKey) } static func saveAccounts( - _ accounts: [CodexReviewAccount], + _ accounts: [CodexSavedAccountPayload], activeAccountKey: String?, codexHomeURL: URL ) throws { @@ -1974,7 +2058,7 @@ private enum CodexReviewAccountRegistry { static func activateAccount( _ accountKey: String, - accounts: [CodexReviewAccount], + accounts: [CodexSavedAccountPayload], codexHomeURL: URL ) throws { let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) @@ -1994,7 +2078,7 @@ private enum CodexReviewAccountRegistry { } static func updateCachedRateLimits( - from account: CodexReviewAccount, + from account: CodexSavedAccountPayload, codexHomeURL: URL ) throws { var registry = try loadRegistry(codexHomeURL: codexHomeURL) @@ -2017,7 +2101,7 @@ private enum CodexReviewAccountRegistry { } static func saveSharedAuth( - for account: CodexReviewAccount, + for account: CodexSavedAccountPayload, codexHomeURL: URL ) throws { try saveSharedAuth( @@ -2029,7 +2113,7 @@ private enum CodexReviewAccountRegistry { static func saveSharedAuth( from sourceCodexHomeURL: URL, - for account: CodexReviewAccount, + for account: CodexSavedAccountPayload, codexHomeURL: URL ) throws { let sourceURL = sharedAuthURL(codexHomeURL: sourceCodexHomeURL) @@ -2081,7 +2165,7 @@ private enum CodexReviewAccountRegistry { return true } - private static func makeAccount(from entry: Entry) -> CodexReviewAccount? { + private static func makePayload(from entry: Entry) -> CodexSavedAccountPayload? { let email = entry.email.trimmingCharacters(in: .whitespacesAndNewlines) guard email.isEmpty == false else { return nil @@ -2091,18 +2175,16 @@ private enum CodexReviewAccountRegistry { .map(CodexReviewAccount.normalizedEmail) .flatMap { $0.isEmpty ? nil : $0 } ?? normalizedEmail - let account = CodexReviewAccount( + return CodexSavedAccountPayload( accountKey: accountKey, email: email, + kind: entry.kind.accountKind, planType: entry.planType, - kind: entry.kind.accountKind + capabilities: entry.kind.accountKind.capabilities, + rateLimits: entry.cachedRateLimits?.map(\.tuple) ?? [], + lastRateLimitFetchAt: entry.lastRateLimitFetchAt, + lastRateLimitError: entry.lastRateLimitError ) - account.updateRateLimits(entry.cachedRateLimits?.map(\.tuple) ?? []) - account.updateRateLimitFetchMetadata( - fetchedAt: entry.lastRateLimitFetchAt, - error: entry.lastRateLimitError - ) - return account } private static func loadRegistry(codexHomeURL: URL) throws -> Registry { @@ -2210,3 +2292,4 @@ private enum CodexReviewAccountRegistry { private static let accountDirectoryNameAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) } +} From fc2d903ff0553f3f9b7047c522849526b34213a7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:41:34 +0900 Subject: [PATCH 20/62] refactor(auth): harden durable account transitions --- .../LiveCodexReviewStoreBackend.swift | 1146 +++++++++++++++-- .../Store/CodexReviewStoreReviews.swift | 13 +- .../CodexReviewHostTests.swift | 365 +++++- .../CodexReviewStoreCommandTests.swift | 36 +- 4 files changed, 1439 insertions(+), 121 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 645106e..7f938b5 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,4 +1,6 @@ import AppKit +import CryptoKit +import Darwin import Foundation import OSLog import CodexAppServerKit @@ -476,7 +478,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) let authSnapshot = try await backend.readAuth() - await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) + try await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) await refreshSelectedAccountRateLimits(auth: store.auth) logger.info("Review runtime started") } catch { @@ -641,7 +643,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } let snapshot = try await appServerBackend.readAuth() - await applyAuthSnapshot(snapshot, to: auth) + try await applyAuthSnapshot(snapshot, to: auth) } catch { auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) } @@ -727,7 +729,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let attachedStore, appServerBackend != nil else { return } - await attachedStore.closeActiveReviewSessions(reason: .system(message: "Account switched.")) + await attachedStore.closeActiveReviewSessions( + reason: .system(message: "Account switched."), + workerDrainTimeout: shutdownCleanupTimeout + ) await stop(store: attachedStore) await start(store: attachedStore, forceRestartIfNeeded: true) } @@ -737,21 +742,30 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { try await withAccountMutation { 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 await accountRegistry.saveAccounts( - remaining.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) - try await accountRegistry.removeSavedAccountDirectory(accountKey: accountKey) if removedActiveAccount { - try? await accountRegistry.removeSharedAuth() + let prepared = try await accountRegistry.prepareIrreversibleRemoval( + accounts: remaining.map(savedAccountPayload(from:)), + activeAccountKey: activeAccountKey + ) + do { + if let appServerBackend { + _ = try await appServerBackend.logout(.init(accountKey)) + } + try await accountRegistry.commitPreparedMutation(prepared) + } catch { + try await abortPreparedAccountMutation(prepared, after: error) + } + } else { + try await accountRegistry.saveAccounts( + remaining.map(savedAccountPayload(from:)), + activeAccountKey: activeAccountKey + ) } + try await accountRegistry.removeSavedAccountDirectory(accountKey: accountKey) auth.applyPersistedAccountStates( remaining.map(savedAccountPayload(from:)), activeAccountKey: activeAccountKey @@ -762,7 +776,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let attachedStore, appServerBackend != nil else { return } - await attachedStore.closeActiveReviewSessions(reason: .system(message: "Account removed.")) + await attachedStore.closeActiveReviewSessions( + reason: .system(message: "Account removed."), + workerDrainTimeout: shutdownCleanupTimeout + ) await stop(store: attachedStore) await start(store: attachedStore, forceRestartIfNeeded: true) } @@ -802,18 +819,25 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let shouldRecycleRuntime = attachedStore != nil && appServerBackend != nil if shouldRecycleRuntime { - await attachedStore?.closeActiveReviewSessions(reason: .system(message: "Signed out.")) - } - if let appServerBackend { - _ = try await appServerBackend.logout(.init(account.accountKey)) + await attachedStore?.closeActiveReviewSessions( + reason: .system(message: "Signed out."), + workerDrainTimeout: shutdownCleanupTimeout + ) } let remaining = auth.persistedAccounts.filter { $0.accountKey != account.accountKey } - try await accountRegistry.saveAccounts( - remaining.map(savedAccountPayload(from:)), + let prepared = try await accountRegistry.prepareIrreversibleRemoval( + accounts: remaining.map(savedAccountPayload(from:)), activeAccountKey: nil ) + do { + if let appServerBackend { + _ = try await appServerBackend.logout(.init(account.accountKey)) + } + try await accountRegistry.commitPreparedMutation(prepared) + } catch { + try await abortPreparedAccountMutation(prepared, after: error) + } try await accountRegistry.removeSavedAccountDirectory(accountKey: account.accountKey) - try? await accountRegistry.removeSharedAuth() auth.updatePhase(.signedOut) auth.selectPersistedAccount(nil) auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:)), activeAccountKey: nil) @@ -851,6 +875,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } + private func abortPreparedAccountMutation( + _ mutation: AccountRegistryStore.PreparedMutation, + after originalError: any Error + ) async throws -> Never { + do { + try await accountRegistry.abortPreparedMutation(mutation) + } catch { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Account mutation failed and its durable journal could not be reconciled. " + + "Original failure: \(originalError.localizedDescription). " + + "Recovery failure: \(error.localizedDescription)" + ) + } + throw originalError + } + private func beginStockLogin( auth: CodexReviewAuthModel, activation: LoginActivation @@ -944,7 +984,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } else { isolatedRateLimits = nil } - let account = await applyAuthSnapshot( + let account = try await applyAuthSnapshot( snapshot, to: auth, activation: session.activation, @@ -953,7 +993,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { if session.runtime.usesPrimaryRuntime == false { if let account, let isolatedRateLimits { applyRateLimits(isolatedRateLimits, to: account) - try? await accountRegistry.updateCachedRateLimits( + try await accountRegistry.updateCachedRateLimits( from: savedAccountPayload(from: account) ) } @@ -1031,7 +1071,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { message: "Authentication committed, but the replacement runtime did not confirm a ChatGPT account." ) } - _ = await applyAuthSnapshotSerialized(snapshot, to: auth) + _ = try await applyAuthSnapshotSerialized(snapshot, to: auth) } } catch let failure as CodexReviewAuthenticationFailure { auth.updatePhase(.failed(failure)) @@ -1191,9 +1231,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, authSourceCodexHomeURL: URL? = nil - ) async -> CodexReviewAccount? { - await accountRuntimeTransitionCoordinator.performInternal { - await self.applyAuthSnapshotSerialized( + ) async throws -> CodexReviewAccount? { + try await accountRuntimeTransitionCoordinator.performInternal { + try await self.applyAuthSnapshotSerialized( snapshot, to: auth, activation: activation, @@ -1208,12 +1248,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, authSourceCodexHomeURL: URL? = nil - ) async -> CodexReviewAccount? { + ) async throws -> 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 { + try await accountRegistry.saveAccounts( + auth.persistedAccounts.map(savedAccountPayload(from:)), + activeAccountKey: nil + ) auth.selectPersistedAccount(nil) auth.updatePhase(.signedOut) } else { @@ -1221,38 +1265,48 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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] + var persistedAccountPayloads = auth.persistedAccounts.map(savedAccountPayload(from:)) + let existingAccount = auth.persistedAccounts.first(where: { $0.accountKey == account.accountKey }) + var authenticatedAccountPayload = savedAccountPayload(from: account) + if let existingAccount { + let existingPayload = savedAccountPayload(from: existingAccount) + authenticatedAccountPayload.rateLimits = existingPayload.rateLimits + authenticatedAccountPayload.lastRateLimitFetchAt = existingPayload.lastRateLimitFetchAt + authenticatedAccountPayload.lastRateLimitError = existingPayload.lastRateLimitError + } + if let index = persistedAccountPayloads.firstIndex(where: { $0.accountKey == account.accountKey }) { + persistedAccountPayloads[index] = authenticatedAccountPayload } else { - persistedAccounts.insert(account, at: 0) - persistedAccount = account + persistedAccountPayloads.insert(authenticatedAccountPayload, at: 0) } let activeAccountKey = activation.resolvedActiveAccountKey( authenticatedAccountKey: account.accountKey, - persistedAccounts: persistedAccounts + persistedAccounts: auth.persistedAccounts + (existingAccount == nil ? [account] : []) ) - try? await accountRegistry.saveAccounts( - persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) - switch activation { - case .activateAuthenticatedAccount: - try? await accountRegistry.saveSharedAuth(for: savedAccountPayload(from: account)) - case .preserveActiveAccount: - if let authSourceCodexHomeURL { - try? await accountRegistry.saveSharedAuth( - from: authSourceCodexHomeURL, - for: savedAccountPayload(from: account) - ) - } + if authenticatedAccountPayload.kind == .chatGPT { + try await accountRegistry.commitAuthenticatedAccount( + authenticatedAccountPayload, + accounts: persistedAccountPayloads, + activeAccountKey: activeAccountKey, + authSourceCodexHomeURL: authSourceCodexHomeURL + ) + } else { + try await accountRegistry.saveAccounts( + persistedAccountPayloads, + activeAccountKey: activeAccountKey + ) + } + let persistedAccount: CodexReviewAccount + if let existingAccount { + existingAccount.updateEmail(account.email) + existingAccount.updateKind(account.kind, capabilities: account.capabilities) + existingAccount.updatePlanType(account.planType) + persistedAccount = existingAccount + } else { + persistedAccount = account } auth.applyPersistedAccountStates( - persistedAccounts.map(savedAccountPayload(from:)), + persistedAccountPayloads, activeAccountKey: activeAccountKey ) auth.selectPersistedAccount(activeAccountKey) @@ -1356,7 +1410,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth: CodexReviewAuthModel ) async { do { - await applyAuthSnapshot(try await backend.readAuth(), to: auth) + try await applyAuthSnapshot(try await backend.readAuth(), to: auth) await refreshSelectedAccountRateLimits(auth: auth) } catch { auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) @@ -1374,9 +1428,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } applyRateLimits(rateLimits, to: selectedAccount) - try? await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: selectedAccount) - ) + _ = await persistRateLimitMetadata(for: selectedAccount) } private func refreshSelectedAccountRateLimits(auth: CodexReviewAuthModel) async { @@ -1396,10 +1448,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let didRefresh = await refreshRateLimits(for: account, using: appServerBackend, source: "active-runtime") if didRefresh { - await persistRefreshedSharedAuth( - from: codexHomeURL, - for: account - ) + do { + try await persistRefreshedSharedAuth( + from: codexHomeURL, + for: account + ) + } catch { + recordRateLimitRefreshFailure(error, account: account) + _ = await persistRateLimitMetadata(for: account) + } } } @@ -1415,9 +1472,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { fetchedAt: Date(), error: "Saved account authentication is not available." ) - try? await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) - ) + _ = await persistRateLimitMetadata(for: account) return } let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) @@ -1437,9 +1492,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } catch { try? FileManager.default.removeItem(at: temporaryCodexHomeURL) account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) - try? await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) - ) + _ = await persistRateLimitMetadata(for: account) } } @@ -1460,15 +1513,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } let response = try await backend.readRateLimits() applyRateLimits(response, to: account) - try? await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) - ) - return true + return await persistRateLimitMetadata(for: account) } catch { recordRateLimitRefreshFailure(error, account: account) - try? await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) - ) + _ = await persistRateLimitMetadata(for: account) return false } } @@ -1553,16 +1601,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func persistRefreshedSharedAuth( from sourceCodexHomeURL: URL?, for account: CodexReviewAccount - ) async { + ) async throws { guard let sourceCodexHomeURL else { return } - try? await accountRegistry.saveSharedAuth( + try await accountRegistry.saveSharedAuth( from: sourceCodexHomeURL, for: savedAccountPayload(from: account) ) } + @discardableResult + private func persistRateLimitMetadata(for account: CodexReviewAccount) async -> Bool { + do { + try await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) + ) + return true + } catch { + 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, @@ -1642,6 +1705,10 @@ private actor AccountRegistryStore { fileprivate let id: UUID } + struct PreparedMutation: Hashable, Sendable { + fileprivate let id: UUID + } + private enum MutationKind { case authentication case account @@ -1685,6 +1752,7 @@ private actor AccountRegistryStore { } func updateCachedRateLimits(from account: CodexSavedAccountPayload) throws { + try requireNoAccountMutationForBackgroundPersistence() try Disk.updateCachedRateLimits(from: account, codexHomeURL: codexHomeURL) } @@ -1692,6 +1760,7 @@ private actor AccountRegistryStore { from sourceCodexHomeURL: URL? = nil, for account: CodexSavedAccountPayload ) throws { + try requireNoAccountMutationForBackgroundPersistence() try Disk.saveSharedAuth( from: sourceCodexHomeURL ?? codexHomeURL, for: account, @@ -1699,8 +1768,38 @@ private actor AccountRegistryStore { ) } - func removeSharedAuth() throws { - try Disk.removeSharedAuth(codexHomeURL: codexHomeURL) + func commitAuthenticatedAccount( + _ authenticatedAccount: CodexSavedAccountPayload, + accounts: [CodexSavedAccountPayload], + activeAccountKey: String?, + authSourceCodexHomeURL: URL? + ) throws { + try Disk.commitAuthenticatedAccount( + authenticatedAccount, + accounts: accounts, + activeAccountKey: activeAccountKey, + authSourceCodexHomeURL: authSourceCodexHomeURL ?? codexHomeURL, + codexHomeURL: codexHomeURL + ) + } + + func prepareIrreversibleRemoval( + accounts: [CodexSavedAccountPayload], + activeAccountKey: String? + ) throws -> PreparedMutation { + try Disk.prepareIrreversibleRemoval( + accounts: accounts, + activeAccountKey: activeAccountKey, + codexHomeURL: codexHomeURL + ) + } + + func commitPreparedMutation(_ mutation: PreparedMutation) throws { + try Disk.commitPreparedMutation(mutation, codexHomeURL: codexHomeURL) + } + + func abortPreparedMutation(_ mutation: PreparedMutation) throws { + try Disk.abortPreparedMutation(mutation, codexHomeURL: codexHomeURL) } func removeSavedAccountDirectory(accountKey: String) throws { @@ -1711,7 +1810,8 @@ private actor AccountRegistryStore { } func copySavedAuth(accountKey: String, to destinationCodexHomeURL: URL) throws -> Bool { - try Disk.copySavedAuth( + try requireNoAccountMutationForBackgroundPersistence() + return try Disk.copySavedAuth( accountKey: accountKey, from: codexHomeURL, to: destinationCodexHomeURL @@ -1747,6 +1847,14 @@ private actor AccountRegistryStore { activeMutation = (lease, kind) return lease } + + private func requireNoAccountMutationForBackgroundPersistence() throws { + guard activeMutation?.kind != .account else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Background account persistence is blocked while an account mutation is in progress." + ) + } + } } @MainActor @@ -1763,7 +1871,7 @@ private final class AccountRuntimeTransitionCoordinator { return try await operation() } - func performInternal(_ operation: () async -> T) async -> T { + func performInternal(_ operation: () async throws -> T) async rethrows -> T { while isTransitioning { await withCheckedContinuation { continuation in transitionCompletionWaiters.append(continuation) @@ -1771,7 +1879,7 @@ private final class AccountRuntimeTransitionCoordinator { } isTransitioning = true defer { finishTransition() } - return await operation() + return try await operation() } private func finishTransition() { @@ -1878,12 +1986,49 @@ private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async thr private extension AccountRegistryStore { enum Disk { 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? @@ -1894,6 +2039,7 @@ enum Disk { enum CodingKeys: String, CodingKey { case accountKey + case immutableRevision case kind case email case planType @@ -1905,6 +2051,7 @@ enum Disk { init( accountKey: String?, + immutableRevision: String? = nil, kind: Kind, email: String, planType: String?, @@ -1914,6 +2061,7 @@ enum Disk { cachedRateLimits: [SavedRateLimitWindow]? ) { self.accountKey = accountKey + self.immutableRevision = immutableRevision self.kind = kind self.email = email self.planType = planType @@ -1926,6 +2074,7 @@ enum Disk { 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. @@ -1995,8 +2144,36 @@ enum Disk { } } + 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 + } + static func load(codexHomeURL: URL) throws -> AccountRegistryStore.Snapshot { let registry = try loadRegistry(codexHomeURL: codexHomeURL) + try garbageCollectOrphanedRevisions( + referencedBy: registry, + codexHomeURL: codexHomeURL + ) let accounts = registry.accounts.compactMap(makePayload(from:)) let activeAccountKey = registry.activeAccountKey .map(CodexReviewAccount.normalizedEmail) @@ -2013,15 +2190,36 @@ enum Disk { codexHomeURL: URL ) throws { let existing = try 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 } - let records = accounts.map { account in + try saveRegistry( + .init( + schemaVersion: existing.schemaVersion, + generation: existing.generation, + contentHash: existing.contentHash, + activeAccountKey: normalizedActiveAccountKey, + accounts: mergedEntries( + accounts, + activeAccountKey: normalizedActiveAccountKey, + existing: existing.accounts + ) + ), + codexHomeURL: codexHomeURL + ) + } + + 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), @@ -2045,15 +2243,11 @@ enum Disk { } entry.lastRateLimitFetchAt = account.lastRateLimitFetchAt entry.lastRateLimitError = account.lastRateLimitError - if account.accountKey == normalizedActiveAccountKey { + if account.accountKey == activeAccountKey { entry.lastActivatedAt = Date() } return entry } - try saveRegistry( - .init(activeAccountKey: normalizedActiveAccountKey, accounts: records), - codexHomeURL: codexHomeURL - ) } static func activateAccount( @@ -2061,20 +2255,122 @@ enum Disk { accounts: [CodexSavedAccountPayload], codexHomeURL: URL ) throws { - let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) - let savedAuthURL = savedAccountAuthURL( - accountKey: normalizedAccountKey, - codexHomeURL: codexHomeURL + 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 + desiredRegistry.accounts = mergedEntries( + accounts, + activeAccountKey: targetAccountKey, + existing: beforeRegistry.accounts ) - guard FileManager.default.fileExists(atPath: savedAuthURL.path) else { - throw CodexReviewAPI.Error.io("Saved authentication is missing for account \(normalizedAccountKey).") - } - try saveAccounts( + desiredRegistry = try nextRegistry(from: desiredRegistry) + var journal = MutationJournal( + id: UUID(), + 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) + try copyAuth(from: savedAuthURL, to: sharedAuthURL(codexHomeURL: codexHomeURL)) + journal.phase = .sharedAuthApplied + try writeJournal(journal, codexHomeURL: codexHomeURL) + try persistRegistry(desiredRegistry, codexHomeURL: codexHomeURL) + journal.phase = .registryCommitted + try writeJournal(journal, codexHomeURL: codexHomeURL) + try removeJournal(codexHomeURL: codexHomeURL) + } + + static func prepareIrreversibleRemoval( + accounts: [CodexSavedAccountPayload], + activeAccountKey: String?, + codexHomeURL: URL + ) throws -> AccountRegistryStore.PreparedMutation { + let beforeRegistry = try loadRegistry(codexHomeURL: codexHomeURL) + let normalizedActiveAccountKey = activeAccountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { key in accounts.contains(where: { $0.accountKey == key }) ? key : nil } + var desiredRegistry = beforeRegistry + desiredRegistry.activeAccountKey = normalizedActiveAccountKey + desiredRegistry.accounts = mergedEntries( accounts, - activeAccountKey: normalizedAccountKey, + activeAccountKey: normalizedActiveAccountKey, + existing: beforeRegistry.accounts + ) + 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 ) - try copyAuth(from: savedAuthURL, to: sharedAuthURL(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." + ) + } + 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 { + 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 + } + try recoverJournal(journal, codexHomeURL: codexHomeURL) } static func updateCachedRateLimits( @@ -2111,6 +2407,76 @@ enum Disk { ) } + static func commitAuthenticatedAccount( + _ authenticatedAccount: CodexSavedAccountPayload, + accounts: [CodexSavedAccountPayload], + activeAccountKey: String?, + authSourceCodexHomeURL: URL, + codexHomeURL: URL + ) 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 + let createdRevision: 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 + createdRevision = nil + } else { + revision = try writeImmutableRevision( + sourceData, + accountKey: authenticatedAccount.accountKey, + codexHomeURL: codexHomeURL + ) + createdRevision = revision + } + let normalizedActiveAccountKey = activeAccountKey + .map(CodexReviewAccount.normalizedEmail) + .flatMap { key in accounts.contains(where: { $0.accountKey == key }) ? key : nil } + 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 + do { + try saveRegistry(desired, codexHomeURL: codexHomeURL) + } catch { + if let createdRevision { + try? FileManager.default.removeItem( + at: immutableAuthURL( + accountKey: authenticatedAccount.accountKey, + revision: createdRevision, + codexHomeURL: codexHomeURL + ) + ) + } + throw error + } + } + static func saveSharedAuth( from sourceCodexHomeURL: URL, for account: CodexSavedAccountPayload, @@ -2120,10 +2486,43 @@ enum Disk { guard FileManager.default.fileExists(atPath: sourceURL.path) else { return } - try copyAuth( - from: sourceURL, - to: savedAccountAuthURL(accountKey: account.accountKey, codexHomeURL: codexHomeURL) + let sourceData = try validatedAuthData(at: sourceURL) + var registry = try loadRegistry(codexHomeURL: codexHomeURL) + guard let index = registry.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: registry.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 ) + registry.accounts[index].immutableRevision = revision + do { + try saveRegistry(registry, codexHomeURL: codexHomeURL) + } catch { + try? FileManager.default.removeItem( + at: immutableAuthURL( + accountKey: account.accountKey, + revision: revision, + codexHomeURL: codexHomeURL + ) + ) + throw error + } } static func removeSharedAuth(codexHomeURL: URL) throws { @@ -2150,14 +2549,18 @@ enum Disk { from sourceCodexHomeURL: URL, to destinationCodexHomeURL: URL ) throws -> Bool { - let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) - let sourceURL = savedAccountAuthURL( - accountKey: normalizedAccountKey, + 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 - ) - guard FileManager.default.fileExists(atPath: sourceURL.path) else { + ) else { return false } + _ = try validatedAuthData(at: sourceURL) try copyAuth( from: sourceURL, to: sharedAuthURL(codexHomeURL: destinationCodexHomeURL) @@ -2188,13 +2591,45 @@ enum Disk { } 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) - return try JSONDecoder().decode(Registry.self, from: data) + 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)" @@ -2205,18 +2640,320 @@ enum Disk { private static func saveRegistry( _ registry: Registry, codexHomeURL: URL + ) throws { + try persistRegistry( + nextRegistry(from: registry), + codexHomeURL: codexHomeURL + ) + } + + 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 ) throws { let url = registryURL(codexHomeURL: codexHomeURL) try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) + 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] + try writeAtomically( + encoder.encode(registry), + to: url, + permissions: 0o600 + ) + } + + 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) + } + } + } + + 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 encoder.encode(registry).write(to: url, options: .atomic) + try FileManager.default.createDirectory( + at: journalURL(codexHomeURL: codexHomeURL).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + 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) + guard FileManager.default.fileExists(atPath: url.path) else { + return + } + try FileManager.default.removeItem(at: url) + try synchronizeDirectory(at: url.deletingLastPathComponent()) + } + + 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) let destinationDirectoryURL = destinationURL.deletingLastPathComponent() try FileManager.default.createDirectory( at: destinationDirectoryURL, @@ -2236,12 +2973,173 @@ enum Disk { } else { try FileManager.default.moveItem(at: replacementURL, to: destinationURL) } + try synchronizeFile(at: destinationURL) + try synchronizeDirectory(at: destinationDirectoryURL) + let destinationData = try validatedAuthData(at: destinationURL) + guard fingerprint(destinationData) == fingerprint(sourceData) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Authentication copy fingerprint mismatch." + ) + } } catch { try? FileManager.default.removeItem(at: replacementURL) throw error } } + 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() + let accountDirectoryURL = directoryURL.deletingLastPathComponent() + let accountsURL = accountDirectoryURL.deletingLastPathComponent() + let codexHomeParentURL = codexHomeURL.deletingLastPathComponent() + let directoryExisted = FileManager.default.fileExists(atPath: directoryURL.path) + let accountDirectoryExisted = FileManager.default.fileExists(atPath: accountDirectoryURL.path) + let accountsDirectoryExisted = FileManager.default.fileExists(atPath: accountsURL.path) + let codexHomeExisted = FileManager.default.fileExists(atPath: codexHomeURL.path) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + 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." + ) + } + 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) + if directoryExisted == false { + try synchronizeDirectory(at: accountDirectoryURL) + } + if accountDirectoryExisted == false { + try synchronizeDirectory(at: accountsURL) + } + if accountsDirectoryExisted == false { + try synchronizeDirectory(at: codexHomeURL) + } + if codexHomeExisted == false { + try synchronizeDirectory(at: codexHomeParentURL) + } + let persisted = try validatedAuthData(at: url) + guard fingerprint(persisted) == fingerprint(data) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Immutable authentication revision fingerprint mismatch." + ) + } + return revision + } catch { + try? FileManager.default.removeItem(at: url) + throw error + } + } + + 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 + ) throws { + let directoryURL = destinationURL.deletingLastPathComponent() + 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." + ) + } + do { + let handle = try FileHandle(forWritingTo: replacementURL) + try handle.write(contentsOf: data) + try handle.synchronize() + try handle.close() + if FileManager.default.fileExists(atPath: destinationURL.path) { + _ = try FileManager.default.replaceItemAt( + destinationURL, + withItemAt: replacementURL + ) + } else { + try FileManager.default.moveItem(at: replacementURL, to: destinationURL) + } + try synchronizeFile(at: destinationURL) + try synchronizeDirectory(at: directoryURL) + } catch { + try? FileManager.default.removeItem(at: replacementURL) + throw error + } + } + + private static func synchronizeFile(at url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + try handle.synchronize() + try handle.close() + } + + 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) @@ -2256,6 +3154,11 @@ enum Disk { .appendingPathComponent("registry.json") } + private static func journalURL(codexHomeURL: URL) -> URL { + accountsDirectoryURL(codexHomeURL: codexHomeURL) + .appendingPathComponent("mutation-journal.json") + } + private static func sharedAuthURL(codexHomeURL: URL) -> URL { codexHomeURL.appendingPathComponent("auth.json") } @@ -2265,6 +3168,31 @@ enum Disk { .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) diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index ccb1588..fc99ac2 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -487,14 +487,23 @@ extension CodexReviewStore { } } - package func closeActiveReviewSessions(reason: ReviewCancellation) async { + @discardableResult + package func closeActiveReviewSessions( + reason: ReviewCancellation, + workerDrainTimeout: Duration + ) async -> Bool { let runIDs = orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) for runID in runIDs { - _ = try? await cancelReview(runID: runID, cancellation: reason) + do { + _ = try await cancelReview(runID: runID, cancellation: reason) + } catch { + continue + } } + return await drainReviewWorkersForRuntimeStop(timeout: workerDrainTimeout) } private func requireReviewRun(runID: String) throws -> ReviewRunRecord { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index b39e3f2..2b8196c 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -430,6 +430,115 @@ struct CodexReviewHostTests { #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.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") @@ -446,7 +555,6 @@ struct CodexReviewHostTests { environment: ["HOME": try temporaryHome().path], transport: transport ) - await store.start(forceRestartIfNeeded: true) await transport.waitForRequestCount(4) await store.refreshAccountRateLimits(accountKey: "api-key") @@ -462,6 +570,8 @@ struct CodexReviewHostTests { } @Test func liveStoreCompletesStockLoginAfterAccountReadiness() async throws { + let homeURL = try temporaryHome() + let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", 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") @@ -492,7 +602,7 @@ struct CodexReviewHostTests { ) let externalURLOpener = FakeExternalURLOpener() let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], + environment: ["HOME": homeURL.path], externalURLOpener: externalURLOpener.open, transport: transport ) @@ -518,6 +628,10 @@ struct CodexReviewHostTests { #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.emitServerNotificationJSON( method: "account/login/completed", json: #"{"loginId":"login-1","success":true,"error":null}"# @@ -594,6 +708,10 @@ struct CodexReviewHostTests { @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.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") try await mainTransport.enqueue( @@ -859,6 +977,13 @@ struct CodexReviewHostTests { 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.emitServerNotification( method: "account/login/completed", params: TestLoginCompletedNotification(loginID: "login-new", success: true) @@ -867,6 +992,7 @@ struct CodexReviewHostTests { method: "account/updated", json: #"{"authMode":"chatgpt","planType":"plus"}"# ) + await transport.waitForRequestCount(7) await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" && store.auth.selectedAccount?.rateLimits.first?.usedPercent == 20 @@ -971,6 +1097,12 @@ struct CodexReviewHostTests { } @Test func liveStoreIgnoresNonCodexRateLimitNotifications() 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 transport = FakeCodexAppServerTransport() try await transport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") try await transport.enqueue( @@ -991,7 +1123,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], + environment: ["HOME": homeURL.path], transport: transport ) @@ -1081,6 +1213,11 @@ struct CodexReviewHostTests { 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( @@ -1514,6 +1651,7 @@ struct CodexReviewHostTests { for: "account/rateLimits/read" ) try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") + try await firstTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") let secondTransport = FakeCodexAppServerTransport() try await secondTransport.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") @@ -1544,6 +1682,181 @@ struct CodexReviewHostTests { #expect(await secondTransport.recordedRequests().map(\.method).contains("account/read")) } + @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"] + ) + try Data("{\"tokens\":{\"id_token\":\"test\"}}".utf8) + .write(to: mainCodexHomeURL.appendingPathComponent("auth.json")) + let logoutGate = AsyncGate() + 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) + )), + for: "account/rateLimits/read" + ) + try await firstTransport.enqueue(EmptyResponse(), for: "account/logout") + try await firstTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") + await firstTransport.holdNext(method: "account/logout", gate: logoutGate) + 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") + var transports = [firstTransport, secondTransport] + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transportFactory: { _ in transports.removeFirst() } + ) + await store.start(forceRestartIfNeeded: true) + + let removal = Task { + try await store.removeAccount(accountKey: "active@example.com") + } + await firstTransport.waitForRequestCount(6) + 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) + try await firstTransport.emitServerNotification( + method: "account/rateLimits/updated", + params: TestRateLimitsUpdatedNotification(rateLimits: .init( + limitID: "codex", + primary: .init(usedPercent: 12, windowDurationMins: 300) + )) + ) + #expect(await waitUntil(timeout: .seconds(1)) { + store.auth.selectedAccount?.rateLimits.first?.usedPercent == 12 + }) + #expect( + try Data(contentsOf: accountMutationJournalURL(homeURL: homeURL)) + == journalData + ) + + 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(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.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) + )), + for: "account/rateLimits/read" + ) + await transport.enqueueFailure( + code: -32603, + message: "logout unavailable", + for: "account/logout" + ) + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + transport: transport + ) + 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 + ) + await store.stop() + } + @Test func liveStoreClosesIsolatedLoginRuntimeWhenMainRuntimeIsUnavailable() async throws { let homeURL = try temporaryHome() try writeRegistry( @@ -2167,6 +2480,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 { @@ -2183,21 +2500,51 @@ 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 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 { diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index ea5a9cd..3cc659f 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -1655,7 +1655,10 @@ struct CodexReviewStoreCommandTests { reviewRuns: [running] ) - await store.closeActiveReviewSessions(reason: .system(message: "Account switched.")) + await store.closeActiveReviewSessions( + reason: .system(message: "Account switched."), + workerDrainTimeout: .seconds(1) + ) #expect(running.core.lifecycle.status == .cancelled) async let result = store.startReview( @@ -1670,6 +1673,37 @@ struct CodexReviewStoreCommandTests { } } + @Test func closeActiveReviewSessionsBoundsStuckStartupDrain() async throws { + let backend = FakeCodexReviewBackend() + 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 startedAt = ContinuousClock.now + + let didDrain = await store.closeActiveReviewSessions( + reason: .system(message: "Account switched."), + workerDrainTimeout: .milliseconds(20) + ) + + #expect(didDrain == false) + #expect(ContinuousClock.now - startedAt < .seconds(1)) + await startGate.open() + let result = try await running.value + #expect(result.core.lifecycle.status == .cancelled) + } + } + @Test func authAndSettingsUseSingleBackendContract() async throws { let backend = FakeCodexReviewBackend(settings: .init(model: "gpt-5")) let store = CodexReviewStore.makeTestingStore( From 261586d0557b9d044b101baaab6ebd8b7e1332b3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:41:38 +0900 Subject: [PATCH 21/62] fix(review-ui): use live chat state for context actions --- .../CodexChats/ReviewMonitorSidebarViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 242c29e..721d83c 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -872,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? { From e9e52b63bed412282df911c8b0575b4e70f92746 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:28:45 +0900 Subject: [PATCH 22/62] refactor(review-ui): consume immutable chat observations --- Docs/rearchitecture-2026-07-10.md | 20 +- Package.resolved | 4 +- Package.swift | 2 +- ...wMonitorCodexChatLogSourceProjection.swift | 230 +++++++++++--- .../ReviewMonitorCodexChatLogTarget.swift | 55 +--- .../ReviewMonitorSidebarViewController.swift | 2 +- .../ReviewMonitorPreviewContent.swift | 11 - .../ReviewMonitorChatLogTesting.swift | 11 - .../ReviewMonitorCodexChatDetailTests.swift | 281 ++++++++++++++---- ...itorCodexSelectionTitleResolverTests.swift | 2 +- 10 files changed, 430 insertions(+), 188 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index b1c0591..5649e28 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -1629,11 +1629,17 @@ public enum CodexChatUpdate: Equatable, Sendable { case turnRemoved(id: CodexTurnID) case itemInserted(item: CodexThreadItem, turnID: CodexTurnID, index: Int) case itemUpdated(item: CodexThreadItem, turnID: CodexTurnID, index: Int) - case itemRemoved(id: String, turnID: CodexTurnID) - case itemTextAppended(id: String, turnID: CodexTurnID, delta: String) + 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にはできない。 @@ -1658,7 +1664,7 @@ public enum CodexChatUpdate: Equatable, Sendable { - `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はIDを直前baselineから削除し、consumerがlive `observation.chat`を再読しないと適用できないID-only insert/updateは削除する。insert先に同IDが存在する、update/remove/text targetが直前baselineにない、indexが範囲外、turnがない場合はすべてcontract violationとする。snapshot compactionはそのcursor以下のupdatesを破棄するため、正当なduplicate removeは生じない。 +- 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から始める。 @@ -3507,7 +3513,7 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { - `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とitemID/turnID→projected block IDsのindexを持ち、次の表どおりevent payloadだけをexact cursor順に適用する。target IDが直前baselineに見つからない場合はinvariant failureとしてtest/logで表面化し、live graphの再読、silent full reprojection、text-searchを行わない。全置換を許すのは全`CodexChatObservationEvent.Payload.snapshot` reasonだけである。 +- 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 | |---|---| @@ -3517,8 +3523,8 @@ public struct CodexThreadItem: Identifiable, Equatable, Sendable { | `.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(id:turnID:))` | indexed item blocksをremove。last review markerなら同turnのretained user itemsだけreinsert | -| `.update(.itemTextAppended(id:turnID:delta:))` | exact prior cursorのaffected text blockへdeltaをappend | +| `.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しない | @@ -3898,7 +3904,7 @@ Phase 4ではこのinventoryを作るのではなく、recursive `public/open` s | `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`, `CodexFetchedResultsController` | typed phase、newest-1 relay lifecycle。String error fieldなし | ReviewUI and package UI tests | -| `NEW` | `CodexTurnTerminalDisposition`, `CodexChatPhase`, `CodexChatSnapshotReason`, `CodexChatObservationSnapshot`, `CodexChatObservationEvent`/`Payload` | immutable terminal/failure/snapshot/cursor facts | ReviewChatLogUI | +| `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 | diff --git a/Package.resolved b/Package.resolved index 5c72a08..9f4b603 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "b44a75be0a328b8eeb5c53d6cf5558348206171df4b58603650b6a9143072803", + "originHash" : "0a92bd0e76c7911376cca908661b5637e26f37d6dae9c734f77bc63c584de113", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "3f6216c01c91bf14737e6fe40c30efef7a5bbd04" + "revision" : "d2958ae43c85c8bfb5b57e8f5eab5382c75d73c4" } }, { diff --git a/Package.swift b/Package.swift index 49c6208..e556740 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 = "d2958ae43c85c8bfb5b57e8f5eab5382c75d73c4" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 597a6ce..38aff58 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -1,3 +1,4 @@ +import CodexAppServerKit import CodexDataKit import Foundation @@ -30,69 +31,208 @@ 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 mutating func reset() { logProjection.reset() + snapshot = nil + cursor = nil hasLogDocument = 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 + cursor = .init(generation: event.generation, sequence: event.sequence) + return renderSnapshot(allowIncrementalUpdate: false) + case .update(let update): + validateUpdateCursor(event) + apply(update) + cursor = .init(generation: event.generation, sequence: event.sequence) + return renderSnapshot(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.") + } + var turns = snapshot.thread.turns ?? [] switch update { - case .resynchronized: - allowsIncrementalUpdate = hasLogDocument - case .turnInserted, - .turnUpdated, - .statusChanged, - .phaseChanged, - .itemInserted, - .itemUpdated, - .itemRemoved, - .itemTextAppended: - allowsIncrementalUpdate = hasLogDocument + 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) + case .turnUpdated(let turn, let index): + let previousIndex = requiredTurnIndex(turn.id, in: turns) + precondition(turns[previousIndex].items == turn.items) + turns.remove(at: previousIndex) + precondition(turns.indices.contains(index) || index == turns.endIndex) + turns.insert(turn, at: index) + case .turnRemoved(let id): + let index = requiredUniqueIndex(in: turns) { $0.id == id } + turns.remove(at: index) + 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) + 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) + } + 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) + 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) + } + turns[turnIndex].items.remove(at: itemIndex) + 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) + } + append(delta, to: &turns[turnIndex].items[itemIndex]) + case .statusChanged(let status): + snapshot.thread.status = status + case .phaseChanged(let phase): + snapshot.phase = phase + } + snapshot.thread.turns = turns + self.snapshot = snapshot + } + + 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 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) } - return renderChat( - from: chat, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt, - allowIncrementalUpdate: allowsIncrementalUpdate - ) } - private mutating func renderChat( - from chat: CodexChat, - chatCreatedAt: Date?, - chatUpdatedAt: Date?, + 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 renderSnapshot( allowIncrementalUpdate: Bool ) -> ReviewMonitorLogSourceChange? { - guard - let document = logProjection.render( - from: chat, - chatCreatedAt: chatCreatedAt, - chatUpdatedAt: chatUpdatedAt - ) + guard let snapshot, + let document = logProjection.render( + from: snapshot.thread, + chatCreatedAt: snapshot.thread.createdAt, + chatUpdatedAt: snapshot.thread.updatedAt + ) else { - return clearIfNeeded() + if allowIncrementalUpdate { + return clearIfNeeded() + } + logProjection.reset() + hasLogDocument = false + return .clear } defer { hasLogDocument = true diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index 87d6e7f..0796b54 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -26,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? @@ -49,7 +48,6 @@ package final class ReviewMonitorCodexChatLogTarget { package init() {} isolated deinit { - selectedChatObservation?.cancel() selectedChatLogTask?.cancel() pendingLogSourceChangeTask?.cancel() logRenderTask?.cancel() @@ -71,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() @@ -89,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 @@ -155,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( @@ -331,11 +306,6 @@ package final class ReviewMonitorCodexChatLogTarget { logRenderer = ReviewMonitorLogRenderer() } - private func cancelSelectedChatObservation() { - selectedChatObservation?.cancel() - selectedChatObservation = nil - } - private func cancelPendingLogSourceChange() { pendingLogSourceChangeTask?.cancel() pendingLogSourceChangeTask = nil @@ -849,7 +819,6 @@ package final class ReviewMonitorCodexChatLogTarget { } selectedChatLogTask?.cancel() selectedChatLogTask = nil - cancelSelectedChatObservation() cancelPendingLogSourceChange() resetLogRenderer() boundChatID = chatID diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 721d83c..1753328 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -279,7 +279,7 @@ 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)] ) } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 5674ab6..46c44ab 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1081,14 +1081,3 @@ private extension CodexTurnSnapshot.State { } } } - -private extension CodexDataPhase { - init(_ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, errorMessage: String?) { - switch lifecycle { - case .queued, .running, .succeeded, .cancelled: - self = .loaded - case .failed: - self = .failed(errorMessage ?? "Review failed") - } - } -} diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 942544d..0b5d7c2 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -889,17 +889,6 @@ private extension CodexTurnSnapshot.State { } } -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") - } - } -} - private func chatLogCommandText(for entry: ReviewChatLogEntryForTesting) -> String { if let command = entry.metadata?.command, command.isEmpty == false { return command diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index a1e60e5..2ed3d33 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -64,9 +64,10 @@ struct ReviewMonitorCodexChatDetailTests { } } - @Test func logResynchronizationCanUpdateAfterInitialBaseline() async throws { + @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, @@ -83,27 +84,26 @@ 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 { @@ -1072,9 +1072,7 @@ 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, state: .inProgress, items: [ @@ -1088,18 +1086,25 @@ 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") @@ -1109,7 +1114,8 @@ struct ReviewMonitorCodexChatDetailTests { 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, @@ -1128,48 +1134,31 @@ struct ReviewMonitorCodexChatDetailTests { ), ] ) - let updatedChat = try await makeProjectionChat( - turns: [ - .init( - id: firstTurnID, - state: .inProgress, - items: [ - .init( - id: "message-review", - kind: .agentMessage, - content: .message(.init( - id: "message-review", - role: .assistant, - text: "Existing review log" - )) - ), - ] - ), + let insertedTurn = CodexTurnSnapshot( + id: secondTurnID, + state: .inProgress, + items: [ .init( - id: secondTurnID, - state: .inProgress, - items: [ - .init( - id: "reasoning-empty", - kind: .reasoning, - content: .reasoning(.empty) - ), - ] + id: "reasoning-empty", + kind: .reasoning, + content: .reasoning(.empty) ), ] ) - let initialChange = projection.applyBaseline( - from: initialChat, - chatCreatedAt: nil, - chatUpdatedAt: nil - ) - let updatedChange = projection.apply( - .itemInserted(id: "reasoning-empty", turnID: secondTurnID), - in: updatedChat, - chatCreatedAt: nil, - chatUpdatedAt: nil - ) + 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 { @@ -1180,6 +1169,166 @@ struct ReviewMonitorCodexChatDetailTests { #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 codexChatRendersThreadAndLiveUpdates() async throws { let runtime = try await CodexAppServerTestRuntime.start() let modelContext = CodexModelContainer(appServer: runtime.server).mainContext diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index a083bb0..fa1cb19 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -143,7 +143,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 ) From f20b5e13a80be815cd74f0ccd027591db2377c78 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:28:51 +0900 Subject: [PATCH 23/62] refactor(review-ui): adopt typed current-v2 preview events --- Package.resolved | 4 +- Package.swift | 2 +- .../ReviewMonitorCodexChatLogProjection.swift | 2 +- ...wMonitorCodexChatLogSourceProjection.swift | 33 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 478 +++++++++++------- .../ReviewMonitorPreviewContent.swift | 287 +++++++---- .../ReviewMonitorChatLogTesting.swift | 134 ++++- .../ReviewMonitorCodexChatDetailTests.swift | 94 ++++ ...itorCodexSelectionTitleResolverTests.swift | 4 +- Tests/ReviewUITests/ReviewUIShellTests.swift | 18 +- Tests/ReviewUITests/ReviewUITests.swift | 67 +-- 11 files changed, 776 insertions(+), 347 deletions(-) diff --git a/Package.resolved b/Package.resolved index 9f4b603..52ea55a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "0a92bd0e76c7911376cca908661b5637e26f37d6dae9c734f77bc63c584de113", + "originHash" : "1b4f9ba4dfd5ce21c93da061f474e71cfc5d004e1efc94e9d4077a182a5654e2", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "d2958ae43c85c8bfb5b57e8f5eab5382c75d73c4" + "revision" : "bd4b53b303b4020a8add142282409486ec4b1741" } }, { diff --git a/Package.swift b/Package.swift index e556740..2e79e59 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 = "d2958ae43c85c8bfb5b57e8f5eab5382c75d73c4" +let codexKitFallbackRevision = "bd4b53b303b4020a8add142282409486ec4b1741" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index d88de9f..ee18a96 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -790,7 +790,7 @@ private struct CodexThreadSnapshotLogItem: CodexChatLogProjectionItem { case .exitedReviewMode: "review-marker:exitedReviewMode" default: - item.id + "\(item.kind.rawValue):\(item.id)" } } } diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 38aff58..3f94228 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -99,9 +99,13 @@ struct ReviewMonitorCodexChatLogSourceProjection { precondition(turns.contains { $0.id == turn.id } == false) precondition(turns.indices.contains(index) || index == turns.endIndex) turns.insert(turn, at: index) - case .turnUpdated(let turn, let index): + case .turnUpdated(var turn, let index): let previousIndex = requiredTurnIndex(turn.id, in: turns) - precondition(turns[previousIndex].items == turn.items) + precondition( + itemsAreSemanticallyEqual(turns[previousIndex].items, turn.items), + "A turnUpdated event changed semantic item ownership." + ) + turn.items = turns[previousIndex].items turns.remove(at: previousIndex) precondition(turns.indices.contains(index) || index == turns.endIndex) turns.insert(turn, at: index) @@ -174,6 +178,31 @@ struct ReviewMonitorCodexChatLogSourceProjection { item.id == id && item.kind == kind } + 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 + && 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): diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 70e2fdb..2e99060 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -50,6 +50,7 @@ private actor ReviewMonitorPreviewCancelledChatIDs { private struct ReviewMonitorPreviewStoredThreadItem: Sendable { var item: CodexThreadItem var turnID: CodexTurnID + var fixtureItem: CodexAppServerTestItem? } @MainActor @@ -108,6 +109,7 @@ final class ReviewMonitorPreviewAppServerRuntime { private var startTask: Task? private var streamTask: Task? private var notificationTask: Task? + private var turnCompletionNotificationCount = 0 private let snapshotMutationQueue = ReviewMonitorPreviewSnapshotMutationQueue() private let archivedChatIDs = ReviewMonitorPreviewArchivedChatIDs() private let cancelledChatIDs = ReviewMonitorPreviewCancelledChatIDs() @@ -136,6 +138,12 @@ final class ReviewMonitorPreviewAppServerRuntime { await threadStore.snapshot(id: 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 @@ -143,6 +151,10 @@ final class ReviewMonitorPreviewAppServerRuntime { return await runtime.transport.recordedRequests(method: "turn/interrupt").count } + func turnCompletionNotificationCountForTesting() -> Int { + turnCompletionNotificationCount + } + func archiveRequestCountForTesting() async -> Int { guard let runtime else { return 0 @@ -151,25 +163,18 @@ final class ReviewMonitorPreviewAppServerRuntime { } func upsertPreviewItem( - id: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, + _ item: CodexAppServerTestItem, to chatID: CodexThreadID ) async { guard let fixture = fixturesByChatID[chatID] else { return } - guard await upsertStoredItem( - id: id, - kind: kind, - content: content, - in: fixture - ) != nil else { + guard let storedItem = await upsertStoredItem(item, in: fixture) else { return } start() enqueueNotification { [weak self] in - await self?.refreshStoredChat(fixture.chatID) + await self?.emitItemLifecycle(storedItem, for: fixture) } } @@ -187,8 +192,6 @@ final class ReviewMonitorPreviewAppServerRuntime { guard let item = await appendStoredText( delta, itemID: itemID, - kind: kind, - content: content, in: chatID ) else { return @@ -385,7 +388,27 @@ final class ReviewMonitorPreviewAppServerRuntime { runtime: runtime ) case .update, .complete: - await refreshStoredChat(fixture.chatID) + guard let fixtureItem = storedItem.fixtureItem else { + preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") + } + await runtime.transport.waitForNotificationStreamCount(1) + await runtime.transport.waitForRequest(method: "thread/read") + switch step.mode { + 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, + item: fixtureItem + ) + case .textDelta: + preconditionFailure("Text deltas are handled above.") + } } } catch { } @@ -403,37 +426,36 @@ 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 - ) + let item = fixtureItem.domainProjection guard let turnIndex = snapshot.turns?.lastIndex(where: { $0.id == turnID }) else { return nil } @@ -442,15 +464,17 @@ final class ReviewMonitorPreviewAppServerRuntime { } else { snapshot.turns?[turnIndex].items.append(item) } - return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) + return ReviewMonitorPreviewStoredThreadItem( + item: item, + turnID: turnID, + fixtureItem: fixtureItem + ) } } private func appendStoredText( _ delta: String, itemID: String, - kind: CodexThreadItem.Kind, - content: CodexThreadItem.Content, in chatID: CodexThreadID ) async -> ReviewMonitorPreviewStoredThreadItem? { guard delta.isEmpty == false, @@ -468,16 +492,42 @@ final class ReviewMonitorPreviewAppServerRuntime { guard let item = snapshot.turns?[turnIndex].items[itemIndex] else { return nil } - return ReviewMonitorPreviewStoredThreadItem(item: item, turnID: turnID) + return ReviewMonitorPreviewStoredThreadItem( + item: item, + turnID: turnID, + fixtureItem: nil + ) } - 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) + preconditionFailure("A preview text delta requires a previously started item.") + } + } + + private func emitItemLifecycle( + _ storedItem: ReviewMonitorPreviewStoredThreadItem, + for fixture: ReviewMonitorPreviewChatLogFixture + ) async { + do { + try await ensureStarted() + guard let runtime, let fixtureItem = storedItem.fixtureItem else { + return + } + await runtime.transport.waitForNotificationStreamCount(1) + await runtime.transport.waitForRequest(method: "thread/read") + if storedItem.item.isTerminalPreviewItem { + try await runtime.notificationEmitter.emitItemCompleted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) + } else { + try await runtime.notificationEmitter.emitItemStarted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) + } + } catch { + preconditionFailure("Failed to emit preview item lifecycle: \(error)") } } @@ -549,18 +599,6 @@ final class ReviewMonitorPreviewAppServerRuntime { } } - private func refreshStoredChat(_ chatID: CodexThreadID) async { - do { - try await ensureStarted() - guard let container else { - return - } - let context = container.mainContext - try await context.refresh(context.model(for: chatID)) - } catch { - } - } - private func emitTextDelta( _ delta: String, itemID: String, @@ -576,73 +614,78 @@ final class ReviewMonitorPreviewAppServerRuntime { 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 ) } } @@ -660,28 +703,6 @@ final class ReviewMonitorPreviewAppServerRuntime { return false } - private func emitOutputDelta( - method: String, - delta: String, - itemID: String, - turnID: CodexTurnID, - chatID: CodexThreadID, - 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 - ) - ) - } - private func emitCancelledState( _ snapshot: CodexThreadSnapshot, for fixture: ReviewMonitorPreviewChatLogFixture @@ -692,26 +713,29 @@ final class ReviewMonitorPreviewAppServerRuntime { 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.notificationEmitter.emitThreadStatusChanged( + threadID: fixture.chatID, + status: .idle ) - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: PreviewTurnCompletedParams( - threadID: fixture.chatID.rawValue, - turn: .init( - id: turnID.rawValue, - status: "interrupted", - completedAt: Int(Date().timeIntervalSince1970) - ) + let turn = snapshot.turns?.last ?? CodexTurnSnapshot( + id: fixture.previewFallbackTurnID, + state: .interrupted + ) + let items = try turn.items.map { + try makePreviewTestItem( + id: $0.id, + kind: $0.kind, + content: $0.content, + cwd: fixture.cwd ) + } + try await runtime.notificationEmitter.emitTurnCompleted( + threadID: fixture.chatID, + turn: CodexAppServerTestTurn(snapshot: turn, items: items) ) + turnCompletionNotificationCount += 1 } catch { + preconditionFailure("Failed to emit a cancelled Preview turn: \(error)") } } } @@ -724,42 +748,152 @@ private struct PreviewTurnInterruptParams: Decodable, Sendable { } } -private struct PreviewThreadArchiveParams: Decodable, Sendable { - var threadID: String +private func makePreviewTestItem( + id: String, + kind: CodexThreadItem.Kind, + content: CodexThreadItem.Content, + cwd: String +) throws -> CodexAppServerTestItem { + switch content { + case .message(let message): + 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): + 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): + 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): + 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)." + ) + } +} - enum CodingKeys: String, CodingKey { - case threadID = "threadId" +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 ([.string(flattenedResult)], nil, nil) } -private struct PreviewThreadStatusParams: Encodable, Sendable { - var threadID: String - var status: Status +private extension CodexJSONValue { + var nilIfJSONNull: Self? { + self == .null ? nil : self + } +} - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case status +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 + } + } + + var previewPatchStatus: CodexAppServerTestItem.PatchStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } } - struct Status: Encodable, Sendable { - var type: String + var previewMCPStatus: CodexAppServerTestItem.MCPStatus { + switch self { + case .some(.completed): .completed + case .some(.failed), .some(.interrupted): .failed + case .some(.inProgress), .some(.unknown), .none: .inProgress + } } } -private struct PreviewTurnCompletedParams: Encodable, Sendable { +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 struct PreviewThreadArchiveParams: Decodable, Sendable { var threadID: String - var turn: Turn enum CodingKeys: String, CodingKey { case threadID = "threadId" - case turn - } - - struct Turn: Encodable, Sendable { - var id: String - var status: String? - var completedAt: Int? - var items: [String] = [] } } @@ -774,26 +908,6 @@ private extension CodexTurnSnapshot.State { } } -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 extension ReviewMonitorPreviewChatLogFixture { var threadSnapshot: CodexThreadSnapshot { threadSnapshot(initialThreadSnapshot) diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 46c44ab..2f28181 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -1,5 +1,6 @@ import Foundation import CodexAppServerKit +import CodexAppServerKitTesting import CodexDataKit @_spi(Testing) import CodexReviewKit import ReviewUI @@ -48,10 +49,20 @@ public final class ReviewMonitorPreviewContentSource { await runtime.snapshotForTesting(chatID: chatID) } + public func observedTurnStateForTesting( + chatID: CodexThreadID + ) -> CodexTurnSnapshot.State? { + runtime.observedTurnStateForTesting(chatID: chatID) + } + public func interruptRequestCountForTesting() async -> Int { await runtime.interruptRequestCountForTesting() } + public func turnCompletionNotificationCountForTesting() -> Int { + runtime.turnCompletionNotificationCountForTesting() + } + public func archiveRequestCountForTesting() async -> Int { await runtime.archiveRequestCountForTesting() } @@ -153,7 +164,7 @@ public enum ReviewMonitorPreviewContent { CodexThreadItem( id: id, kind: kind, - content: content + content: content.rebindingMessageID(to: id) ) } } @@ -169,7 +180,7 @@ public enum ReviewMonitorPreviewContent { CodexThreadItem( id: id, kind: kind, - content: content + content: content.rebindingMessageID(to: id) ) } } @@ -216,7 +227,7 @@ public enum ReviewMonitorPreviewContent { let accounts = makePreviewAccounts() 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( @@ -298,6 +309,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)) @@ -335,8 +356,12 @@ 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))" + )), delayBeforeFrameCount: 1 ), .init( @@ -367,7 +392,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 @@ -382,6 +408,8 @@ public enum ReviewMonitorPreviewContent { kind: .mcpToolCall, content: .toolCall( .init( + server: "codex_review", + name: "review_read", result: "MCP codex_review.review_read started.", status: .inProgress )), @@ -414,7 +442,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 @@ -431,21 +460,31 @@ public enum ReviewMonitorPreviewContent { ), .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" + )), delayBeforeFrameCount: compactionCompletionDelayFrameCount ), .init( kind: .mcpToolCall, content: .toolCall( .init( + server: "codex_review", + name: "review_read", result: "File changes updated.", status: .completed )), @@ -487,7 +526,9 @@ public enum ReviewMonitorPreviewContent { 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 @@ -503,10 +544,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", @@ -521,10 +561,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 @@ -637,7 +681,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( @@ -721,18 +769,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, @@ -761,51 +797,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: .inProgress - )) - ) + 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( @@ -813,47 +868,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. @@ -862,25 +944,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), @@ -888,17 +967,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. @@ -919,26 +1000,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)", @@ -950,10 +1029,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 ++++++++-- @@ -967,7 +1049,8 @@ public enum ReviewMonitorPreviewContent { ), commandStartedItem( sourceReadItemName, - command: "/bin/zsh -lc \"\(sourceReadCommand)\"" + command: "/bin/zsh -lc \"\(sourceReadCommand)\"", + cwd: cwd ), toolCallItem( "running-tool-\(workspaceName)-\(definition.targetSummary)", @@ -1064,6 +1147,16 @@ private extension CodexThreadStatus { } } +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 CodexTurnSnapshot.State { init( _ lifecycle: ReviewMonitorPreviewContent.PreviewChatLifecycle, diff --git a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index 0b5d7c2..b613213 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import CodexAppServerKitTesting import CodexAppServerKit import CodexDataKit @_spi(Testing) @testable import CodexReviewKit @@ -252,7 +253,7 @@ func installPreviewChatLogSourceForTesting( 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 @@ -619,25 +620,35 @@ private enum ReviewChatLogFixtureStore { private final class ReviewChatLogFixtureRetainer { weak var store: CodexReviewStore? let runtime: ReviewMonitorPreviewAppServerRuntime - private var chatIDs: Set + private var cwdByChatID: [CodexThreadID: String] - init(store: CodexReviewStore, runtime: ReviewMonitorPreviewAppServerRuntime, chatIDs: Set) { + init( + store: CodexReviewStore, + runtime: ReviewMonitorPreviewAppServerRuntime, + 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 { @@ -660,8 +671,107 @@ 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 } } } @@ -754,7 +864,7 @@ private struct ReviewChatLogAccumulatedItem { status: status )) ) - case .agentMessage: + case .agentMessage, .progress: snapshot = .init( id: itemID, kind: .agentMessage, @@ -774,20 +884,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( @@ -795,7 +905,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), diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 2ed3d33..ad48281 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -1110,6 +1110,100 @@ struct ReviewMonitorCodexChatDetailTests { #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") diff --git a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift index fa1cb19..8bd5492 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -152,7 +152,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!) } } diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index 5e7cb32..a211854 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -134,6 +134,10 @@ extension ReviewUITests { 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 @@ -2130,8 +2134,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 { @@ -2148,21 +2152,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) @@ -2178,7 +2180,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) diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 946097b..f738917 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -1148,7 +1148,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", @@ -1190,30 +1190,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) } @@ -1481,7 +1459,8 @@ struct ReviewUITests { viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) _ = try await awaitChatRenderForTesting( - chat, + chatID: chat.chatID, + expectedLog: "Running swift test", in: transport, allowIncrementalUpdate: false ) @@ -1506,7 +1485,11 @@ struct ReviewUITests { 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) @@ -2366,9 +2349,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 ) @@ -2687,14 +2671,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) @@ -3227,11 +3211,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) @@ -3386,12 +3370,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) @@ -3426,9 +3410,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 ) From 6ef07175f5a66ff336e8940da13bcdf74092c584 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:10:05 +0900 Subject: [PATCH 24/62] fix(preview): preserve streaming after chat actions --- .../ReviewMonitorChatContextMenuView.swift | 34 ++++++-- ...ReviewMonitorPreviewAppServerRuntime.swift | 67 +++++++++++----- .../ReviewMonitorPreviewContent.swift | 22 +++++- Tests/ReviewUITests/ReviewUIShellTests.swift | 78 ++++++++++++++++++- 4 files changed, 172 insertions(+), 29 deletions(-) diff --git a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift index 74eb021..4024d1c 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorChatContextMenuView.swift @@ -2,8 +2,14 @@ import AppKit 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 @@ -62,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)" + ) } } } @@ -85,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/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 2e99060..b148b12 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -123,6 +123,12 @@ final class ReviewMonitorPreviewAppServerRuntime { ) } + isolated deinit { + startTask?.cancel() + streamTask?.cancel() + notificationTask?.cancel() + } + var initialChatID: CodexThreadID? { fixtures.first?.chatID } @@ -213,6 +219,7 @@ final class ReviewMonitorPreviewAppServerRuntime { runtime: runtime ) } catch { + preconditionFailure("Failed to append Preview text: \(error)") } } } @@ -233,6 +240,7 @@ final class ReviewMonitorPreviewAppServerRuntime { do { try await self?.startNow() } catch { + preconditionFailure("Failed to start the Preview app-server runtime: \(error)") } } } @@ -256,15 +264,29 @@ final class ReviewMonitorPreviewAppServerRuntime { } } + func cancelStreaming() { + streamTask?.cancel() + } + + func stopStreaming() async { + let task = streamTask + streamTask = nil + task?.cancel() + await task?.value + await notificationTask?.value + } + @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) + var runningFixtures: [(index: Int, fixture: ReviewMonitorPreviewChatLogFixture)] = [] + for (index, fixture) in fixtures.filter(\.isRunning).enumerated() { + if await cancelledChatIDs.contains(fixture.chatID) == false, + await archivedChatIDs.contains(fixture.chatID) == false + { + runningFixtures.append((index, fixture)) } } guard runningFixtures.isEmpty == false else { @@ -275,26 +297,28 @@ final class ReviewMonitorPreviewAppServerRuntime { do { try await ensureStarted() } catch { - return currentTick + preconditionFailure("Failed to start the Preview app-server runtime while streaming: \(error)") } } let nextTick = currentTick + 1 - for (index, fixture) in runningFixtures.enumerated() { - guard let frame = ReviewMonitorPreviewContent.streamFrame( - forRunningChatAt: index, - tick: nextTick - ) else { + 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 + } + if emitsNotifications { + enqueueNotification { [weak self] in + await self?.emit(frame.step, storedItem: storedItem, for: fixture) + } } - } } tick = nextTick return nextTick @@ -411,6 +435,7 @@ final class ReviewMonitorPreviewAppServerRuntime { } } } catch { + preconditionFailure("Failed to emit a Preview stream item: \(error)") } } @@ -537,8 +562,11 @@ final class ReviewMonitorPreviewAppServerRuntime { ) 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 { + await self.cancelledChatIDs.contains(fixture.chatID) == false, + await self.archivedChatIDs.contains(fixture.chatID) == false, + var snapshot = await self.threadStore.snapshot(id: fixture.chatID), + let item = mutation(&snapshot) + else { return nil } await self.replaceThreadStorePreservingFixtureOrder( @@ -596,6 +624,7 @@ final class ReviewMonitorPreviewAppServerRuntime { try await rebindRuntimeToCurrentThreadStore(runtime) } } catch { + preconditionFailure("Failed to rebind the Preview thread store: \(error)") } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 2f28181..1d70050 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -34,6 +34,18 @@ public final class ReviewMonitorPreviewContentSource { runtime.startStreaming(interval: interval) } + package func startStreamingForTesting(interval: Duration) { + runtime.startStreaming(interval: interval) + } + + package func cancelStreamingForTesting() { + runtime.cancelStreaming() + } + + package func stopStreamingForTesting() async { + await runtime.stopStreaming() + } + @discardableResult public func appendPreviewChatLogStreamTick( after tick: Int = 0, @@ -122,7 +134,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, @@ -362,6 +374,7 @@ public enum ReviewMonitorPreviewContent { role: .assistant, text: "Turn started: \(previewTurnID(1))" )), + mode: .complete, delayBeforeFrameCount: 1 ), .init( @@ -373,6 +386,7 @@ public enum ReviewMonitorPreviewContent { [in_progress] Preserve active find UI while streaming [pending] Run focused UI tests """), + mode: .complete, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -402,6 +416,7 @@ public enum ReviewMonitorPreviewContent { exitCode: 0, status: .completed )), + mode: .complete, delayBeforeFrameCount: commandCompletionDelayFrameCount ), .init( @@ -413,6 +428,7 @@ public enum ReviewMonitorPreviewContent { result: "MCP codex_review.review_read started.", status: .inProgress )), + mode: .update, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -456,6 +472,7 @@ public enum ReviewMonitorPreviewContent { exitCode: 0, status: .completed )), + mode: .complete, delayBeforeFrameCount: commandCompletionDelayFrameCount ), .init( @@ -477,6 +494,7 @@ public enum ReviewMonitorPreviewContent { role: .assistant, text: "Context automatically compacted" )), + mode: .complete, delayBeforeFrameCount: compactionCompletionDelayFrameCount ), .init( @@ -488,6 +506,7 @@ public enum ReviewMonitorPreviewContent { result: "File changes updated.", status: .completed )), + mode: .complete, delayBeforeFrameCount: interItemDelayFrameCount ), .init( @@ -520,6 +539,7 @@ 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 ), ] diff --git a/Tests/ReviewUITests/ReviewUIShellTests.swift b/Tests/ReviewUITests/ReviewUIShellTests.swift index a211854..321625c 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -103,6 +103,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 @@ -166,6 +177,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 { @@ -359,11 +384,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 @@ -390,6 +428,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 { @@ -2266,6 +2318,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 { From 1ca382c5412167cab965bf020a234c7dd8439c2a Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:01:23 +0900 Subject: [PATCH 25/62] docs(architecture): refine rearchitecture contracts Clarify that Apple APIs are design analogs rather than implementation templates, align DataKit conformance and observation ownership, and complete restart, retention, MCP, and presentation lifecycle contracts. --- Docs/rearchitecture-2026-07-10.md | 139 +++++++++++------------------- 1 file changed, 51 insertions(+), 88 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index 5649e28..debc49c 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -209,11 +209,11 @@ CodexAppServer facade | `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、mutation strategy | query/mutationの意味論を call site に漏らさない | -| `CodexModelContainer` | app server とeager MainActor context composition | checked `Sendable`。main context生成/transaction deliveryにfire-and-forget Taskやpending replay bufferを作らない | +| `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` transaction relay | current items/sections/phase、newest full old/new snapshot transaction、subscribers | relayはbuffer newest 1、consumer mismatchはnew snapshot replace、owner deinitでfinish | +| `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しない | @@ -267,10 +267,10 @@ CodexAppServer facade | `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` | checked `Sendable` class。mutable contextは`@MainActor` | Taskを作らない | main contextをeager生成し、transaction deliveryはMainActor structured callをcallerがawait。lazy pending replay Taskを削除 | +| `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` transaction relay | model-context isolationへcaller-confined | Taskを作らない | full old/new snapshot transactionとbuffer-newest-1 subscribersを所有し、owner deinitでfinish | +| `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 | @@ -480,6 +480,7 @@ public struct CodexReviewSession: Identifiable, Sendable { 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 } @@ -703,6 +704,11 @@ public actor CodexAppServer { delivery: CodexReviewDelivery = .inline, threadOptions: CodexThread.ResumeOptions = .init() ) async throws -> CodexReviewSession + public func discardPreparedReviewRestart( + _ token: CodexReviewRestartToken + ) async -> [CodexReviewIdentity] + public func discardAllPreparedReviewRestarts() + async -> [CodexThreadID: [CodexReviewIdentity]] public func cleanupReview( _ identity: CodexReviewIdentity, additionalCleanupThreadIDs: [[CodexThreadID]] = [] @@ -726,7 +732,7 @@ public actor CodexAppServer { } ``` -`prepareReviewRestart` / `restartPreparedReview`は`ReviewRestartCoordinator`へ委譲し、tokenごとのstateを次に固定する。 +`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 { @@ -989,7 +995,7 @@ public struct CodexReviewSession { - 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`だけを提供する。 +- 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を解放する。 @@ -1111,6 +1117,17 @@ legacy `applyPatchApproval` / `execCommandApproval` requestはcurrent-v2 invento ### 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) @@ -1173,14 +1190,11 @@ public enum CodexChatPhase: Equatable, Sendable { public var turnID: CodexTurnID? { get } } -public final class CodexModelContainer: Sendable { +public final class CodexModelContainer: Equatable, Sendable { public let appServer: CodexAppServer @MainActor public let mainContext: CodexModelContext @MainActor public init(appServer: CodexAppServer) - @MainActor public convenience init( - configuration: CodexAppServer.Configuration = .init() - ) async throws } public protocol CodexModelActor: Actor { @@ -1202,31 +1216,16 @@ public extension CodexModelActor { var modelContext: CodexModelContext { get } } -public final class CodexModelContext { +public final class CodexModelContext: Equatable, SendableMetatype { public init(_ container: CodexModelContainer) public nonisolated(nonsending) func fetch( _ descriptor: CodexFetchDescriptor ) async throws -> [Model] - public nonisolated(nonsending) func fetch( - _ request: CodexFetchRequest - ) async throws -> [Model] public func fetchedResults( for descriptor: CodexFetchDescriptor, sectionedBy: CodexSectionDescriptor? = nil ) -> CodexFetchedResults - public func fetchedResults( - for request: CodexFetchRequest, - sectionedBy: CodexSectionDescriptor? = nil - ) -> CodexFetchedResults - public func fetchedResultsController( - for descriptor: CodexFetchDescriptor, - sectionedBy: CodexSectionDescriptor? = nil - ) -> CodexFetchedResultsController - public func fetchedResultsController( - for request: CodexFetchRequest, - sectionedBy: CodexSectionDescriptor? = nil - ) -> CodexFetchedResultsController public func model(for id: CodexThreadID) -> CodexChat public func registeredModel(for id: CodexThreadID) -> CodexChat? @@ -1278,6 +1277,9 @@ public final class CodexModelContext { 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? @@ -1292,6 +1294,7 @@ public final class CodexItem: CodexPersistentModel { 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. } @@ -1342,33 +1345,15 @@ public struct CodexFetchDescriptor: Sendable { public var sortBy: [CodexSortDescriptor] public var fetchLimit: Int? public var fetchOffset: Int? - public var includePendingChanges: Bool + public var includeContextChanges: Bool public init( predicate: Predicate? = nil, sortBy: [CodexSortDescriptor] = [], fetchLimit: Int? = nil, fetchOffset: Int? = nil, - includePendingChanges: Bool = true - ) -} - -public final class CodexFetchRequest { - public var predicate: Predicate? - public var sortDescriptors: [CodexSortDescriptor] - public var fetchLimit: Int? - public var fetchOffset: Int? - public var includePendingChanges: Bool - public var fetchDescriptor: CodexFetchDescriptor { get set } - - public init( - predicate: Predicate? = nil, - sortDescriptors: [CodexSortDescriptor] = [], - fetchLimit: Int? = nil, - fetchOffset: Int? = nil, - includePendingChanges: Bool = true + includeContextChanges: Bool = true ) - public init(_ descriptor: CodexFetchDescriptor) } package enum CodexFetchPlanResult { @@ -1406,32 +1391,23 @@ public struct CodexQuery: DynamicProperty { public init( _ descriptor: CodexFetchDescriptor = .init(), - animation: Animation? = nil, - sectionBy: CodexSectionDescriptor? = nil - ) - public init( - _ request: CodexFetchRequest, - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public init( filter: Predicate? = nil, sort: [CodexSortDescriptor] = [], - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public init( filter: Predicate? = nil, sort keyPath: any KeyPath & Sendable, order: SortOrder = .forward, - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public init( filter: Predicate? = nil, sort keyPath: any KeyPath & Sendable, order: SortOrder = .forward, - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public init( @@ -1439,7 +1415,6 @@ public struct CodexQuery: DynamicProperty { sort keyPath: any KeyPath & Sendable, comparator: String.StandardComparator = .localizedStandard, order: SortOrder = .forward, - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public init( @@ -1447,7 +1422,6 @@ public struct CodexQuery: DynamicProperty { sort keyPath: any KeyPath & Sendable, comparator: String.StandardComparator = .localizedStandard, order: SortOrder = .forward, - animation: Animation? = nil, sectionBy: CodexSectionDescriptor? = nil ) public mutating func update() @@ -1549,26 +1523,9 @@ public final class CodexFetchedResults { public private(set) var nextCursor: String? public private(set) var backwardsCursor: String? public private(set) var phase: CodexFetchPhase - - public nonisolated(nonsending) func performFetch() async throws - public nonisolated(nonsending) func refresh() async throws - public nonisolated(nonsending) func loadNextPage() async throws -} - -public final class CodexFetchedResultsController { - public let fetchedResults: CodexFetchedResults - public var modelContext: CodexModelContext { get } - public var fetchDescriptor: CodexFetchDescriptor { get } - public var sectionBy: CodexSectionDescriptor? { get } - public var items: [Model] { get } - public var sections: [CodexFetchSection] { get } public var snapshot: CodexFetchedResultsSnapshot { get } - public var nextCursor: String? { get } - public var backwardsCursor: String? { get } - public var phase: CodexFetchPhase { get } public var transactions: AsyncStream> { get } - public init(fetchedResults: CodexFetchedResults) public nonisolated(nonsending) func performFetch() async throws public nonisolated(nonsending) func refresh() async throws public nonisolated(nonsending) func loadNextPage() async throws @@ -1643,11 +1600,13 @@ public struct CodexChatItemLocator: Equatable, Hashable, Sendable { ``` - 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生成をしない。 +- `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不能を検証する。 -- `CodexQuery` のkey-path convenience initializer familyも`CodexSortDescriptor`と同じrequired/optional ComparableおよびString comparator + `SortOrder`へ置換する。`CodexQueryResults` / `CodexFetchedResults` / controllerの`lastErrorDescription`は削除し、read-only `CodexFetchPhase`だけをfailure ownerにする。 -- FRC transactionは`oldSnapshot/newSnapshot`のfull identity snapshotを常に含み、public streamは`bufferingNewest(1)`とする。consumerのcurrent snapshotがtransaction.oldと一致すればgranular changesを適用し、不一致ならnew snapshotへreplaceするため、中間transaction dropでも整合する。FRC deinit/explicit owning view teardownで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`、`CodexFetchRequest.sortDescriptors`、`CodexQuery` initializers、README/全consumerを同じwaveで `[CodexSortDescriptor]` へ移す。Foundation APIの完全なparallel DSLは作らない。 +- `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を混ぜない。 @@ -1655,7 +1614,9 @@ public struct CodexChatItemLocator: Equatable, Hashable, Sendable { - 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` 注入は行わない。 -- `.both` archive scopeはserver cursorを共有できないmulti-scope queryとして扱う。`archived:false` と `archived:true` を別々にserver endまで全page取得し、eligible pending/live context modelsをmerge(`includePendingChanges == false` なら省略)→ thread IDでdedupe → literal predicate → local effective sort → local offset → local limitの順に適用する。effective sortは明示descriptor、空ならpinned upstream `thread/list` と同じ `createdAt` descending、その後はprimary descriptorと同方向のthread ID tie-breakである。server-ordered single-page pathや一方のcursorをもう一方へ流用する経路へ入れない。`.both` resultは取得時点でserver endなので、そのsnapshotに対する `loadNextPage()` はno-opである。 +- 全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を送らない。 @@ -2938,7 +2899,7 @@ package struct BackendReviewAttempt: Sendable { - `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を持たず、`CodexTranscript(items: refreshedItems.map(\.threadItem)).reviewOutputText`というASKの同じKEEP ownerでoutputを抽出する。projected review outputが存在しnonemptyでexpected raw valueと完全一致した場合だけ`.completed(ReviewCompletion)`を返せる。 +- 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で表面化させる。 @@ -2955,7 +2916,7 @@ package struct BackendReviewAttempt: Sendable { - 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`は同じgeneration handle stateのcompact SDK terminalをnonblockingに読み、同じobserved mapperへ通すpackage operationで、未知ならnilを返しwire request/collector/barrierを開始しない。これは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へ補完しない。 +- `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になる経路はない。 @@ -3859,10 +3820,10 @@ Phase 4ではこのinventoryを作るのではなく、recursive `public/open` s | 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/cleanup、model/account/rate/config read-write、stock `loginChatGPT`/logoutだけを残す。custom server-request handlerはpublic configurationから除く | `CodexReviewAppServer`, `CodexReviewHost`, `CodexDataKit` | +| `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:)`, `cancel()`, `closeConnection()`だけ。public `AsyncSequence`/progress streamを公開しない | CRK adapter | +| `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` | `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 | @@ -3900,10 +3861,12 @@ Phase 4ではこのinventoryを作るのではなく、recursive `public/open` s | `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`, `CodexFetchRequest`, `CodexQuery`, `CodexQueryResults`; query environment modifiers | §5.6のpredicate/sort/limit/offset/pending fieldsとtyped phase。initializer crash/string failureを除く | ReviewUI, external 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`, `CodexFetchedResultsController` | typed phase、newest-1 relay lifecycle。String error fieldなし | 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 | @@ -4064,8 +4027,8 @@ Terminal status は upstream が定義する closed enum であり plugin-style | 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/FRC | 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 | -| 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、nil active-only、`.both` active+archived/pending merge/dedupe/explicit-or-createdAt-desc sort + same-direction ID tie-break/offset/limit with pending on/off、Mirror removal | +| 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 | From 146affaae8b256954e5bbde361fc4d1616078cdd Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:20:23 +0900 Subject: [PATCH 26/62] fix(review-monitor): stop preview store on termination --- .../CodexReviewMonitorApp.swift | 3 --- .../CodexReviewMonitorCITests.swift | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index 2eaee98..c49b6d4 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -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 } diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index ae79f04..1d2ec4f 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 From ee013a430579c7e49cb2ae61ead875cd9d3c4915 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:54:13 +0900 Subject: [PATCH 27/62] refactor(review): centralize lifecycle ownership Move review attempts, cancellation, retention, MCP sessions, projections, and preview runtime resources under explicit owners. Align Host/AppServer integration with the current CodexKit v2 contracts and require shutdown to join cleanup work. --- Package.swift | 3 +- .../AppServerCodexReviewBackend.swift | 917 +++++---- .../LiveCodexReviewStoreBackend.swift | 916 +++++---- .../ReviewBackendEventSession.swift | 186 -- .../ReviewObservationAwaiter.swift | 118 +- .../CodexReviewKit/CodexReviewBackend.swift | 204 +- Sources/CodexReviewKit/CodexReviewTypes.swift | 172 +- .../CodexReviewKit/Model/ReviewIdentity.swift | 134 ++ .../CodexReviewKit/Model/ReviewRunCore.swift | 150 +- .../Model/ReviewRunPresentation.swift | 94 + .../Model/ReviewRunRecord.swift | 18 +- .../Model/ReviewRunRecordTesting.swift | 197 +- .../CodexReviewKit/Model/ReviewTerminal.swift | 74 + .../CodexReviewNetworkMonitoring.swift | 1 - .../ReviewAPI/ReviewResults.swift | 47 +- .../Store/CodexReviewStore.swift | 71 +- .../Store/CodexReviewStoreBackend.swift | 47 +- .../Store/CodexReviewStoreCancellation.swift | 275 +-- .../Store/CodexReviewStoreOrderQueries.swift | 20 +- .../Store/CodexReviewStoreReviews.swift | 1453 +++----------- .../Store/CodexReviewStoreRuntimeState.swift | 366 +++- .../PreviewCodexReviewStoreBackend.swift | 44 +- .../Store/ReviewStoreCommitSink.swift | 282 +++ .../Store/ReviewStoreWorker.swift | 1164 +++++++++++ .../Store/ReviewThreadRetentionRegistry.swift | 771 ++++++++ .../CodexReviewMCPHTTPServer.swift | 1084 +++++++++-- .../CodexReviewMCPProtocolServer.swift | 148 +- .../CodexReviewMCPServer.swift | 152 +- .../CodexReviewMCPToolRequests.swift | 32 +- .../CodexReviewMCPToolResults.swift | 273 ++- .../MCPReviewSessionRegistry.swift | 337 ++++ .../ReviewChatProjectionLookup.swift | 25 + .../ReviewMCPLogProjection.swift | 41 +- .../ReviewRunIDArgument.swift | 17 +- Sources/CodexReviewTesting/TestSupport.swift | 615 +++++- .../ReviewMonitorCodexChatLogProjection.swift | 300 ++- ...wMonitorCodexChatLogSourceProjection.swift | 269 ++- .../ReviewPresentationPolicies.swift | 90 + ...ewMonitorCodexSelectionTitleResolver.swift | 2 +- .../ReviewMonitorSidebarViewController.swift | 44 +- .../PreviewRuntimeLifetime.swift | 473 +++++ ...ReviewMonitorPreviewAppServerRuntime.swift | 742 ++++--- .../ReviewMonitorPreviewContent.swift | 123 +- .../AppServerClientTests.swift | 1585 +++++++++------ .../CodexReviewHostTests.swift | 1715 ++++++++--------- .../ReviewBackendEventSessionTests.swift | 128 -- .../ReviewObservationAwaiterTests.swift | 31 +- .../CodexReviewNetworkMonitoringTests.swift | 76 + .../CodexReviewStoreCommandTests.swift | 1157 +++++++---- ...ReviewStoreRateLimitAutoRefreshTests.swift | 8 +- .../ReviewRunRecordTests.swift | 124 +- .../ReviewThreadRetentionRegistryTests.swift | 522 +++++ .../CodexReviewMCPHTTPServerTests.swift | 1007 ++++++++-- .../CodexReviewMCPServerTests.swift | 114 +- .../MCPReviewSessionRegistryTests.swift | 261 +++ .../ReviewMCPLogProjectionTests.swift | 87 +- .../PreviewRuntimeLifetimeTests.swift | 85 + .../ReviewMonitorChatLogTesting.swift | 67 +- .../ReviewMonitorCodexChatDetailTests.swift | 812 +++++--- ...itorCodexSelectionTitleResolverTests.swift | 71 +- ...eviewMonitorCodexSidebarResultsTests.swift | 255 ++- Tests/ReviewUITests/ReviewUIShellTests.swift | 135 +- Tests/ReviewUITests/ReviewUITests.swift | 116 +- 63 files changed, 14109 insertions(+), 6738 deletions(-) delete mode 100644 Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift create mode 100644 Sources/CodexReviewKit/Model/ReviewIdentity.swift create mode 100644 Sources/CodexReviewKit/Model/ReviewRunPresentation.swift create mode 100644 Sources/CodexReviewKit/Model/ReviewTerminal.swift create mode 100644 Sources/CodexReviewKit/Store/ReviewStoreCommitSink.swift create mode 100644 Sources/CodexReviewKit/Store/ReviewStoreWorker.swift create mode 100644 Sources/CodexReviewKit/Store/ReviewThreadRetentionRegistry.swift create mode 100644 Sources/CodexReviewMCPServer/MCPReviewSessionRegistry.swift create mode 100644 Sources/CodexReviewMCPServer/ReviewChatProjectionLookup.swift create mode 100644 Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift create mode 100644 Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift delete mode 100644 Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift create mode 100644 Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift create mode 100644 Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift create mode 100644 Tests/CodexReviewMCPServerTests/MCPReviewSessionRegistryTests.swift create mode 100644 Tests/ReviewUITests/PreviewRuntimeLifetimeTests.swift diff --git a/Package.swift b/Package.swift index 2e79e59..86738a7 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 = "bd4b53b303b4020a8add142282409486ec4b1741" +let codexKitFallbackRevision = "abe61366030e4d7fdf788fae62ada53d21b6cd62" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) @@ -162,6 +162,7 @@ let package = Package( dependencies: [ .product(name: "CodexAppServerKit", package: "CodexKit"), .product(name: "CodexAppServerKitTesting", package: "CodexKit"), + .product(name: "CodexDataKit", package: "CodexKit"), "CodexReviewAppServer", "CodexReviewKit", "CodexReviewTesting", diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index fc2a9fe..0d941e8 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -18,29 +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] = [:] - private var completedThreadCleanupAttemptIDs: Set = [] - - 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) } @@ -93,24 +83,33 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } 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) @@ -119,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( @@ -128,7 +127,6 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { delivery: .inline ) ) - .session } private func reviewThreadOptions( @@ -151,32 +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 } - _ = try await cancelReviewTurn(for: run) + _ = 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 ) } @@ -184,329 +178,466 @@ 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 appServer.restartPreparedReview( - appServerToken, - target: request.request.target.appServerReviewTarget, - delivery: .inline, - threadOptions: reviewThreadOptions(model: interruptedRun.model ?? request.model) - ) - 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 - ) - 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() - 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 - } + 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) + ) } - if completedThreadCleanupAttemptIDs.contains(run.attemptID) == false { - deferredThreadCleanupsByAttemptID[run.attemptID] = .init( - run: run, - additionalCleanupThreadIDs: additionalCleanupThreadIDs + 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)" ) } - for threadID in cleanupThreadIDs { - reviewEventSessionCanonicalThreadIDByThreadID.removeValue(forKey: threadID) - activeReviewAttemptIDByThreadID.removeValue(forKey: threadID) + let activeReview = ActiveReviewSession( + attempt: recoveredAttempt, + session: review, + chat: modelContext.model(for: review.activeTurnThreadID) + ) + 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 + ) + ) + + return retainedIdentities.map { identity in + 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)" + ) + } } } - private func flushDeferredThreadCleanups() async { - let deferredCleanups = deferredThreadCleanupsByAttemptID.values - deferredThreadCleanupsByAttemptID = [:] - completedThreadCleanupAttemptIDs.formUnion( - deferredCleanups.map { $0.run.attemptID } + 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.cleanupReviewReportingFailures( + firstAttempt.appServerReviewIdentity, + additionalCleanupThreadIDs: attempts.dropFirst().map { attempt in + attempt.appServerReviewIdentity.cleanupThreadIDs + } + [additionalThreadIDs.map { CodexThreadID(rawValue: $0.rawValue) }] ) - for cleanup in deferredCleanups { - await cleanupAppServerReview( - cleanup.run, - additionalCleanupThreadIDs: cleanup.additionalCleanupThreadIDs + 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 projectedOutput == candidate.expectedOutput else { + return .failed(.outputPublication(.mismatched(turnID: candidate.turnID))) } - guard let attemptID else { return nil } - return reviewEventSessionsByAttemptID[attemptID] + 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 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 markRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) { - inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] += 1 + private func markAttemptAbandoned(_ attempt: ReviewAttempt) { + abandonedReviewAttemptIDs.insert(attempt.attemptID) } - private func clearRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) { - let count = inFlightRestartCountByInterruptedAttemptID[run.attemptID, default: 0] + private func markRestartInFlight(forInterrupted attempt: ReviewAttempt) { + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID, default: 0] += 1 + } + + private func clearRestartInFlight(forInterrupted attempt: ReviewAttempt) { + let count = inFlightRestartCountByInterruptedAttemptID[attempt.attemptID, default: 0] if count <= 1 { - inFlightRestartCountByInterruptedAttemptID.removeValue(forKey: run.attemptID) + inFlightRestartCountByInterruptedAttemptID.removeValue(forKey: attempt.attemptID) } else { - inFlightRestartCountByInterruptedAttemptID[run.attemptID] = count - 1 + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID] = count - 1 } } - private func isRestartInFlight(forInterrupted run: CodexReviewBackendModel.Review.Run) -> Bool { - inFlightRestartCountByInterruptedAttemptID[run.attemptID] != nil + private func isRestartInFlight(forInterrupted attempt: ReviewAttempt) -> Bool { + inFlightRestartCountByInterruptedAttemptID[attempt.attemptID] != nil } - 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 - } - return Array(runsByAttemptID.values) + 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 enum AppServerTypedReviewEventAdapter { - static func started( - review: CodexReviewSession, - run: CodexReviewBackendModel.Review.Run - ) -> CodexReviewBackendModel.Review.Event { - .started( - turnID: review.turnID.rawValue, - reviewThreadID: review.reviewThreadID.rawValue, - model: run.model - ) - } - - static func convert( - _ outcome: CodexTurnOutcome - ) -> CodexReviewBackendModel.Review.Event { +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 finalReview = response.transcript.reviewOutputText?.nilIfEmpty else { - return .failed(.missingReviewOutput(turnID: response.turnID.rawValue)) + guard let rawOutput = response.transcript.reviewOutputText, + let output = try? NonEmptyReviewOutput(validating: rawOutput) else { + return .failed(.missingReviewOutput(turnID: turnID)) } - return .completed(finalReview: finalReview) + return .completed(.init(turnID: turnID, expectedOutput: output)) case .interrupted: return .interrupted(message: nil) case .failed(let failedTurn): - return .failed(.turnFailed(map(failedTurn.error))) - case .invalidTerminalStatus(let rawStatus, let error, let response): + return .failed(.turnFailed(turnFailure(failedTurn.error))) + case .invalidTerminalStatus(let rawStatus, let error, _): return .failed( .invalidTerminalStatus( rawStatus: rawStatus, - turnID: response.turnID.rawValue, - turnFailure: error.map(map) + turnID: turnID, + turnFailure: error.map(turnFailure) ) ) } } - private static func map(_ error: CodexTurnError) -> ReviewTurnFailure { + static func failure(_ error: any Error) -> ReviewBackendFailure { + if let failure = error as? ReviewBackendFailure { + return failure + } + 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)" + ) + } + 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)" + ) + } + } + + static func turnFailure(_ error: CodexTurnError) -> ReviewTurnFailure { .init( message: error.message, - code: error.info.map(map), + code: error.info.map(turnFailureCode), additionalDetails: error.additionalDetails ) } - private static func map(_ info: CodexErrorInfo) -> ReviewTurnFailure.Code { + private static func turnFailureCode(_ info: CodexErrorInfo) -> ReviewTurnFailure.Code { switch info { case .contextWindowExceeded: .contextWindowExceeded @@ -546,155 +677,111 @@ private enum AppServerTypedReviewEventAdapter { } } -private actor AppServerReviewEventSession { - private let pipeline: ReviewBackendEventSession - private var terminalCollectionTask: 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 finish() async { - cancelTerminalCollection() - await pipeline.finish(throwing: nil) - } - - func finish(throwing error: (any Error)?) async { - cancelTerminalCollection() - await pipeline.finish(throwing: error) - } - - func abandon() async { - cancelTerminalCollection() - await pipeline.abandon() - } - - func metricsSnapshot() async -> ReviewBackendEventSessionMetrics { - await pipeline.metricsSnapshot() - } - - func activeStreamSubscriptionIDForTesting() -> Int? { - nil - } - - func detach(subscriptionID _: Int) {} - - func startConsuming(_ review: CodexReviewSession) { - guard terminalCollectionTask == nil else { - return - } - reviewSession = review - terminalCollectionTask = 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 { - terminalCollectionTask = 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), - controlThreadID: review.reviewThreadID.rawValue - ) - do { - let outcome = try await review.collect() - await receive( - AppServerTypedReviewEventAdapter.convert(outcome), - controlThreadID: review.reviewThreadID.rawValue + 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 ) - } catch is CancellationError { - await finish(throwing: CancellationError()) - } catch { - await finish(throwing: error) } } - - private func receive( - _ event: CodexReviewBackendModel.Review.Event, - controlThreadID: String - ) async { - await pipeline.receive([event], controlThreadID: controlThreadID) - } - - private func cancelTerminalCollection() { - terminalCollectionTask?.cancel() - terminalCollectionTask = 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 { diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 7f938b5..79a966d 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -28,57 +28,9 @@ private let defaultExternalURLOpener: ExternalURLOpener = { url in } } -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() - } - return result +private enum RuntimeReviewCleanupMode { + case connected + case connectionTerminated } package struct CodexReviewMCPPortOwner: Equatable, Sendable { @@ -103,9 +55,19 @@ 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 @@ -126,7 +88,6 @@ public extension CodexReviewStore { externalURLOpener: @escaping ExternalURLOpener = defaultExternalURLOpener, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServer: CodexAppServer, @@ -138,7 +99,6 @@ public extension CodexReviewStore { externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, @@ -157,7 +117,6 @@ 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, @@ -171,7 +130,6 @@ public extension CodexReviewStore { mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, appServerLifecycleHandler: appServerLifecycleHandler, appServerRuntimeFactory: { codexHomeURL in let appServer = try await appServerFactory(codexHomeURL) @@ -180,7 +138,6 @@ public extension CodexReviewStore { appServer: appServer, modelContainer: modelContainer, backend: AppServerCodexReviewBackend( - appServer: appServer, modelContainer: modelContainer ) ) @@ -219,7 +176,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private let accountRegistry: AccountRegistryStore private let accountRuntimeTransitionCoordinator: AccountRuntimeTransitionCoordinator private let registryLoadFailure: CodexReviewAuthenticationFailure? - private let shutdownCleanupTimeout: Duration private let appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? private weak var attachedStore: CodexReviewStore? @@ -238,7 +194,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { }, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, appServerRuntimeFactory: AppServerRuntimeFactory? = nil ) { @@ -258,7 +213,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.mcpHTTPServerFactory = mcpHTTPServerFactory self.mcpPortOwnerResolver = mcpPortOwnerResolver ?? Self.defaultMCPPortOwnerResolver self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker - self.shutdownCleanupTimeout = shutdownCleanupTimeout self.appServerLifecycleHandler = appServerLifecycleHandler self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory( codexExecutablePath: runtimePreferences.codexExecutablePath @@ -285,6 +239,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer != nil } + var reviewThreadRetentionCodexHomePath: String { + codexHomeURL.path + } + + var reviewThreadRetentionJournalURL: URL? { + codexHomeURL.appendingPathComponent("review-thread-retention.json", isDirectory: false) + } + var invokesRuntimeStopReviewCleanupDuringStop: Bool { true } @@ -420,7 +382,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer: appServer, modelContainer: modelContainer, backend: AppServerCodexReviewBackend( - appServer: appServer, modelContainer: modelContainer ) ) @@ -444,7 +405,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } if forceRestartIfNeeded { - await stop(store: store) + await stop(store: store, purpose: .runtimeRestartPreservingRuns) } var startedAppServer: CodexAppServer? @@ -472,13 +433,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpHTTPServerConfiguration, logProjectionProvider ) - try await mcpHTTPServer.start() + try await mcpHTTPServer.stage() startedHTTPServer = mcpHTTPServer self.mcpHTTPServer = mcpHTTPServer } - store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) let authSnapshot = try await backend.readAuth() try await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) + switch await store.recoverOrphanedReviewThreads() { + case .recovered, .cleanupIncomplete: + break + case .journalUnavailable(let message): + throw ReviewBackendFailure.retentionJournal(message: message) + } + store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) + await self.mcpHTTPServer?.activate() await refreshSelectedAccountRateLimits(auth: store.auth) logger.info("Review runtime started") } catch { @@ -525,50 +493,57 @@ 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) async { + func stop(store: CodexReviewStore, purpose: CodexReviewRuntimeStopPurpose) async { let appServer = appServer let appServerBackend = appServerBackend let mcpHTTPServer = mcpHTTPServer let hasRuntimeState = appServer != nil || appServerBackend != nil || mcpHTTPServer != nil let loginSession = self.loginSession - self.loginSession = nil guard hasRuntimeState || loginSession != nil else { + if purpose.retiresRuns { + _ = await store.retireReviewRunsForFinalStoreStop() + } return } logger.info("Stopping review runtime") + await mcpHTTPServer?.stop() + self.mcpHTTPServer = nil if let appServerBackend { let reason = ReviewCancellation.system(message: "Review runtime stopped.") await cleanupActiveReviewsForRuntimeTeardown( store: store, appServerBackend: appServerBackend, reason: reason, - timeoutWarning: "Timed out cleaning active reviews before stopping runtime" + mode: .connected ) } + _ = await loginSession?.terminate(reason: .storeStop) + if purpose.retiresRuns { + guard await store.retireReviewRunsForFinalStoreStop() else { + logger.error("Review runtime remains open because an unpersisted cleanup quarantine is unresolved") + return + } + } self.appServer = nil clearAppServerModelContainer() - self.mcpHTTPServer = nil authNotificationTask?.cancel() authNotificationTask = nil - await mcpHTTPServer?.stop() self.appServerBackend = nil - await terminateLoginSession(loginSession) await appServer?.close() logger.info("Review runtime stopped") } @@ -650,16 +625,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func signIn(auth: CodexReviewAuthModel) async throws { - try await beginStockLogin(auth: auth, activation: .activateAuthenticatedAccount) + try await attachedStore?.requireReviewThreadRetentionAcceptance() + try await beginStockLogin(auth: auth, purpose: .signIn) } func addAccount(auth: CodexReviewAuthModel) async throws { + try await attachedStore?.requireReviewThreadRetentionAcceptance() let activeAccountKey = auth.persistedActiveAccountKey ?? auth.selectedAccount?.accountKey try await beginStockLogin( auth: auth, - activation: activeAccountKey != nil - ? .preserveActiveAccount(activeAccountKey) - : .activateAuthenticatedAccount + purpose: activeAccountKey != nil + ? .addAccountPreservingActive(activeAccountKey) + : .signIn ) } @@ -670,48 +647,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } return } - let resultTask = session.takeResultTask() - resultTask?.cancel() - do { - let outcome = try await session.handle.cancel() - await resultTask?.value - if case .authenticationCommittedNeedsConnectionReconciliation(let reason) = outcome, - case .activateAuthenticatedAccount = session.activation - { - await reconcilePrimaryAuthentication( - session: session, - reason: reason, - auth: auth - ) - return - } - loginSession = nil - switch outcome { - case .cancelled: - auth.updatePhase(.signedOut) - case .failed(let message): - updateAuthenticationFailure( - message ?? "Authentication cancellation failed.", - auth: auth, - activation: session.activation - ) - case .succeeded, .authenticationCommittedNeedsConnectionReconciliation: - updateAuthenticationFailure( - "Authentication could not be safely committed after cancellation.", - auth: auth, - activation: session.activation - ) - } - } catch { - loginSession = nil - auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) - } - await resultTask?.value - await closeLoginRuntimeIfNeeded(session) - await releaseLoginMutationIfNeeded(session) + _ = await session.terminate(reason: .explicitCancellation) } func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { + try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { return @@ -730,15 +670,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } await attachedStore.closeActiveReviewSessions( - reason: .system(message: "Account switched."), - workerDrainTimeout: shutdownCleanupTimeout + reason: .system(message: "Account switched.") ) - await stop(store: attachedStore) + await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) await start(store: attachedStore, forceRestartIfNeeded: true) } } func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { + try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { let removedActiveAccount = auth.selectedAccount?.accountKey == accountKey || auth.persistedActiveAccountKey == accountKey @@ -777,10 +717,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return } await attachedStore.closeActiveReviewSessions( - reason: .system(message: "Account removed."), - workerDrainTimeout: shutdownCleanupTimeout + reason: .system(message: "Account removed.") ) - await stop(store: attachedStore) + await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) await start(store: attachedStore, forceRestartIfNeeded: true) } } @@ -811,6 +750,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { + try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { guard let account = auth.selectedAccount else { auth.updatePhase(.signedOut) @@ -820,8 +760,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let shouldRecycleRuntime = attachedStore != nil && appServerBackend != nil if shouldRecycleRuntime { await attachedStore?.closeActiveReviewSessions( - reason: .system(message: "Signed out."), - workerDrainTimeout: shutdownCleanupTimeout + reason: .system(message: "Signed out.") ) } let remaining = auth.persistedAccounts.filter { $0.accountKey != account.accountKey } @@ -842,7 +781,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth.selectPersistedAccount(nil) auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:)), activeAccountKey: nil) if shouldRecycleRuntime, let attachedStore { - await stop(store: attachedStore) + await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) await start(store: attachedStore, forceRestartIfNeeded: true) } } @@ -893,161 +832,332 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func beginStockLogin( auth: CodexReviewAuthModel, - activation: LoginActivation + purpose: LoginPurpose ) async throws { guard loginSession == nil else { throw CodexReviewAuthenticationFailure.alreadyInProgress } let mutationLease = try await accountRegistry.beginAuthenticationMutation() var runtimeRequiringCleanup: LoginRuntime? + var mutationLeaseRequiringRelease: AccountRegistryStore.MutationLease? = mutationLease + var sessionOwnsFailurePublication = false do { - let runtime = try await loginRuntime(for: activation) + let runtime = try await loginRuntime(for: purpose) runtimeRequiringCleanup = runtime let handle = try await runtime.appServer.loginChatGPT( accountReadinessTimeout: .seconds(10) ) + let generationID = UUID() let session = LoginSession( + generationID: generationID, + purpose: purpose, handle: handle, runtime: runtime, - activation: activation, - mutationLease: mutationLease + mutationLease: mutationLease, + rootOperation: { @MainActor [weak self] in + let observation: LoginRootObservation + do { + observation = .outcome(try await handle.result()) + } catch is CancellationError { + observation = .waiterCancelled(message: nil) + } catch { + observation = .failure( + .runtime(message: error.localizedDescription) + ) + } + self?.publishLoginRootObservation( + observation, + generationID: generationID, + handleID: handle.id + ) + return observation + }, + terminationHandler: { @MainActor [weak self, auth] session, reason, observation in + guard let self else { + return .stopped + } + return await self.finishLoginSession( + session, + reason: reason, + observation: observation, + auth: auth + ) + } ) loginSession = session + runtimeRequiringCleanup = nil + mutationLeaseRequiringRelease = nil + sessionOwnsFailurePublication = true auth.updatePhase(.signingIn(.init( title: "Sign in to Codex", detail: "Continue signing in with your browser.", browserURL: handle.authenticationURL.absoluteString, userCode: nil ))) + session.activate() do { try await externalURLOpener(handle.authenticationURL) } catch { - loginSession = nil - _ = try? await handle.cancel() - throw CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) - } - let sessionID = session.id - session.installResultTask(Task { @MainActor [weak self, weak auth] in - guard let self, let auth else { + let failure = CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) + let terminal = await session.terminate(reason: .urlOpenFailure(failure)) + switch terminal { + case .succeeded: + return + case .failed(let terminalFailure): + throw terminalFailure + case .cancelled, .stopped: return } - do { - let outcome = try await handle.result() - await self.finishStockLogin( - sessionID: sessionID, - outcome: outcome, - auth: auth - ) - } catch is CancellationError { - } catch { - await self.finishStockLogin( - sessionID: sessionID, - failure: error, - auth: auth - ) - } - }) - runtimeRequiringCleanup = nil + } } catch { await closeLoginRuntime(runtimeRequiringCleanup) - await accountRegistry.finishMutation(mutationLease) + if let mutationLeaseRequiringRelease { + await accountRegistry.finishMutation(mutationLeaseRequiringRelease) + } let failure = (error as? CodexReviewAuthenticationFailure) ?? .runtime(message: error.localizedDescription) - updateAuthenticationFailure( - failure.localizedDescription, - auth: auth, - activation: activation - ) - if case .preserveActiveAccount = activation { + if sessionOwnsFailurePublication == false { + auth.updatePhase(.failed(failure)) + } + if case .addAccountPreservingActive = purpose { throw failure } } } - private func finishStockLogin( - sessionID: UUID, - outcome: CodexLoginOutcome, - auth: CodexReviewAuthModel - ) async { - guard let session = loginSession, session.id == sessionID else { + private func publishLoginRootObservation( + _ observation: LoginRootObservation, + generationID: UUID, + handleID: CodexLoginHandle.ID + ) { + guard let session = loginSession, + session.generationID == generationID, + session.handle.id == handleID 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) + await releaseLoginMutationIfNeeded(session) + clearLoginSessionIfCurrent(session) + return terminal + } + + private func finishLoginOutcome( + _ outcome: CodexLoginOutcome, + session: LoginSession, + reason terminationReason: LoginTerminationReason, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { switch outcome { case .succeeded: - do { - let snapshot = try await session.runtime.backend.readAuth() - let isolatedRateLimits: CodexRateLimits? - if session.runtime.usesPrimaryRuntime == false { - isolatedRateLimits = try? await session.runtime.backend.readRateLimits() - await session.runtime.appServer.close() - session.markOwnedRuntimeClosed() - } else { - isolatedRateLimits = nil - } - let account = try await applyAuthSnapshot( - snapshot, - to: auth, - activation: session.activation, - authSourceCodexHomeURL: session.runtime.codexHomeURL - ) - if session.runtime.usesPrimaryRuntime == false { - if let account, let isolatedRateLimits { - applyRateLimits(isolatedRateLimits, to: account) - try await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) - ) - } - try? FileManager.default.removeItem(at: session.runtime.codexHomeURL) - } else { - await refreshSelectedAccountRateLimits(auth: auth) - } - } catch { - updateAuthenticationFailure( - error.localizedDescription, - auth: auth, - activation: session.activation - ) - } - session.markResultCompleted() - guard session.isReadyForCleanup else { - return - } + return await finishSuccessfulLogin(session: session, auth: auth) case .failed(let message): - updateAuthenticationFailure( - message ?? "Authentication failed.", - auth: auth, - activation: session.activation + let failure = CodexReviewAuthenticationFailure.login( + message: message ?? "Authentication failed." ) + auth.updatePhase(.failed(failure)) + return .failed(failure) case .cancelled: - auth.updatePhase(.signedOut) - case .authenticationCommittedNeedsConnectionReconciliation(let reason): - if case .activateAuthenticatedAccount = session.activation { - await reconcilePrimaryAuthentication( + return finishCancelledLoginOutcome( + reason: terminationReason, + auth: auth + ) + case .authenticationCommittedNeedsConnectionReconciliation(let reconciliationReason): + if case .signIn = session.purpose, + sessionAllowsPrimaryReconciliation(terminationReason: terminationReason) + { + return await reconcilePrimaryAuthentication( session: session, - reason: reason, + reason: reconciliationReason, auth: auth ) - return } - updateAuthenticationFailure( - "Authentication requires runtime reconciliation: \(String(describing: reason))", - auth: auth, - activation: session.activation + let failure = terminationFailure( + for: terminationReason, + fallback: .login( + message: "Authentication requires runtime reconciliation: \(String(describing: reconciliationReason))" + ) ) + if let failure { + auth.updatePhase(.failed(failure)) + return .failed(failure) + } + if auth.selectedAccount == nil { + auth.updatePhase(.signedOut) + } + return .stopped + } + } + + private func finishSuccessfulLogin( + session: LoginSession, + auth: CodexReviewAuthModel + ) async -> LoginSessionTerminal { + var stagingURLRequiringRemoval: URL? + defer { + if let stagingURLRequiringRemoval { + try? FileManager.default.removeItem(at: stagingURLRequiringRemoval) + } + } + do { + let snapshot = try await session.runtime.backend.readAuth() + let isolatedRateLimits: CodexRateLimits? + if session.runtime.usesPrimaryRuntime == false { + isolatedRateLimits = try? await session.runtime.backend.readRateLimits() + guard let runtime = session.takeOwnedRuntimeForClose() else { + preconditionFailure("An isolated login runtime can be closed only once.") + } + await runtime.appServer.close() + stagingURLRequiringRemoval = runtime.codexHomeURL + } else { + isolatedRateLimits = nil + } + let account = try await applyAuthSnapshot( + snapshot, + to: auth, + activation: session.purpose.activation, + authSourceCodexHomeURL: session.runtime.codexHomeURL + ) + if session.runtime.usesPrimaryRuntime == false { + if let account, let isolatedRateLimits { + applyRateLimits(isolatedRateLimits, to: account) + try await accountRegistry.updateCachedRateLimits( + from: savedAccountPayload(from: account) + ) + } + } else { + await refreshSelectedAccountRateLimits(auth: auth) + } + return .succeeded + } catch let failure as CodexReviewAuthenticationFailure { + auth.updatePhase(.failed(failure)) + return .failed(failure) + } catch { + let failure = CodexReviewAuthenticationFailure.login( + message: error.localizedDescription + ) + auth.updatePhase(.failed(failure)) + return .failed(failure) + } + } + + 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 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 terminationFailure( + for reason: LoginTerminationReason, + fallback: CodexReviewAuthenticationFailure + ) -> CodexReviewAuthenticationFailure? { + switch reason { + case .rootOutcome: + return nil + case .explicitCancellation: + return fallback + case .urlOpenFailure(let failure), .runtimeFailure(let failure): + return failure + case .storeStop: + return nil + } + } + + private func sessionAllowsPrimaryReconciliation( + terminationReason: LoginTerminationReason + ) -> Bool { + switch terminationReason { + case .rootOutcome, .explicitCancellation, .urlOpenFailure: + return true + case .runtimeFailure, .storeStop: + return false } - await finishLoginSessionCleanup(session) } private func reconcilePrimaryAuthentication( session: LoginSession, reason: CodexLoginReconciliationReason, auth: CodexReviewAuthModel - ) async { - guard loginSession === session else { - return + ) async -> LoginSessionTerminal { + guard detachLoginSessionIfCurrent(session) else { + return .stopped } - loginSession = nil - session.takeResultTask() do { try await accountRuntimeTransitionCoordinator.perform { guard let store = attachedStore else { @@ -1055,7 +1165,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { message: "Authentication committed, but the review store is unavailable for reconciliation." ) } - await stop(store: store) + await stop(store: store, purpose: .loginReconciliationPreservingRuns) await start(store: store, forceRestartIfNeeded: true) guard let backend = appServerBackend else { throw CodexReviewAuthenticationFailure.runtime( @@ -1073,45 +1183,39 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } _ = try await applyAuthSnapshotSerialized(snapshot, to: auth) } + return .succeeded } catch let failure as CodexReviewAuthenticationFailure { auth.updatePhase(.failed(failure)) logger.error( "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(failure.localizedDescription, privacy: .public)" ) + return .failed(failure) } catch { let failure = CodexReviewAuthenticationFailure.runtime(message: error.localizedDescription) auth.updatePhase(.failed(failure)) logger.error( "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(error.localizedDescription, privacy: .public)" ) + return .failed(failure) } - await releaseLoginMutationIfNeeded(session) } - private func finishStockLogin( - sessionID: UUID, - failure: any Error, - auth: CodexReviewAuthModel - ) async { - guard let session = loginSession, session.id == sessionID else { + private func clearLoginSessionIfCurrent(_ session: LoginSession) { + guard loginSession === session, + loginSession?.generationID == session.generationID else { return } - updateAuthenticationFailure( - failure.localizedDescription, - auth: auth, - activation: session.activation - ) - await finishLoginSessionCleanup(session) + loginSession = nil } - private func finishLoginSessionCleanup(_ session: LoginSession) async { - guard loginSession === session else { - return + @discardableResult + private func detachLoginSessionIfCurrent(_ session: LoginSession) -> Bool { + guard loginSession === session, + loginSession?.generationID == session.generationID else { + return false } loginSession = nil - session.takeResultTask() - await closeLoginRuntimeIfNeeded(session) - await releaseLoginMutationIfNeeded(session) + return true } private func closeLoginRuntime(_ runtime: LoginRuntime?) async { @@ -1128,11 +1232,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func closeLoginRuntimeIfNeeded(_ session: LoginSession) async { - guard session.ownedRuntimeNeedsClose else { + guard let runtime = session.takeOwnedRuntimeForClose() else { return } - await closeLoginRuntime(session.runtime) - session.markOwnedRuntimeClosed() + await closeLoginRuntime(runtime) } private func releaseLoginMutationIfNeeded(_ session: LoginSession) async { @@ -1142,22 +1245,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await accountRegistry.finishMutation(lease) } - private func updateAuthenticationFailure( - _ message: String, - auth: CodexReviewAuthModel, - activation: LoginActivation - ) { - switch activation { - case .activateAuthenticatedAccount: - auth.updatePhase(.failed(.login(message: message))) - case .preserveActiveAccount: - auth.updatePhase(.failed(.login(message: message))) - } - } - - 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.") } @@ -1167,7 +1257,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { codexHomeURL: codexHomeURL, usesPrimaryRuntime: true ) - case .preserveActiveAccount: + case .addAccountPreservingActive: let temporaryCodexHomeURL = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true) let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) @@ -1194,18 +1284,18 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return try await appServerBackend.startReview(request) } - func interruptReview(_ run: CodexReviewBackendModel.Review.Run, reason: CodexReviewBackendModel.CancellationReason) async throws { + func interruptReview(_ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws { guard let appServerBackend 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 { 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( @@ -1218,11 +1308,58 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return try await appServerBackend.restartPreparedReview(token, request: request) } - func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { + func discardPreparedReviewRestart( + _ token: CodexReviewBackendModel.Review.RestartToken + ) async -> [ReviewAttempt] { + guard let appServerBackend 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 else { return } - await appServerBackend.cleanupReview(run) + await appServerBackend.cleanupReview(attempt) + } + + func cleanupRetainedReviews( + _ attempts: [ReviewAttempt], + additionalThreadIDs: [ReviewThreadID] + ) async -> ReviewRetainedThreadCleanupResult { + guard let appServerBackend 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 .init(failures: attemptFailures + additionalThreadIDs.map { threadID in + .init( + threadID: threadID, + message: "The matching review runtime is not running." + ) + }) + } + return await appServerBackend.cleanupRetainedReviews( + attempts, + additionalThreadIDs: additionalThreadIDs + ) } @discardableResult @@ -1354,30 +1491,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { store: CodexReviewStore ) async { let loginSession = self.loginSession - self.loginSession = nil guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginSession != nil else { return } let message = "Review runtime stopped unexpectedly: \(error.localizedDescription)" + let failedAppServer = appServer + let failedMCPHTTPServer = mcpHTTPServer + await failedMCPHTTPServer?.stop() + mcpHTTPServer = nil 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" + mode: .connectionTerminated ) } - let failedAppServer = appServer - let failedMCPHTTPServer = mcpHTTPServer + _ = await loginSession?.terminate( + reason: .runtimeFailure(.runtime(message: message)) + ) appServer = nil clearAppServerModelContainer() appServerBackend = nil - mcpHTTPServer = nil authNotificationTask = nil store.transitionToFailed(message) - await failedMCPHTTPServer?.stop() - await terminateLoginSession(loginSession) await failedAppServer?.close() } @@ -1388,11 +1526,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) async { switch event { case .accountUpdated: - if let loginSession { - loginSession.markAccountUpdateConsumed() - if loginSession.isReadyForCleanup { - await finishLoginSessionCleanup(loginSession) - } + if loginSession != nil { return } await refreshAuthAfterAccountNotification(backend: backend, auth: auth) @@ -1565,22 +1699,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { try? FileManager.default.removeItem(at: codexHomeURL) } - private func terminateLoginSession(_ session: LoginSession?) async { - guard let session else { - return - } - let resultTask = session.takeResultTask() - resultTask?.cancel() - do { - _ = try await session.handle.cancel() - } catch { - logger.error("Failed to cancel login during teardown: \(error.localizedDescription, privacy: .public)") - } - await resultTask?.value - await closeLoginRuntimeIfNeeded(session) - await releaseLoginMutationIfNeeded(session) - } - private func applyRateLimits( _ rateLimits: CodexRateLimits, to account: CodexReviewAccount @@ -1689,10 +1807,10 @@ private struct AppServerRuntime: Sendable { @MainActor private struct LoginRuntime: Sendable { - var appServer: CodexAppServer - var backend: AppServerCodexReviewBackend - var codexHomeURL: URL - var usesPrimaryRuntime: Bool + let appServer: CodexAppServer + let backend: AppServerCodexReviewBackend + let codexHomeURL: URL + let usesPrimaryRuntime: Bool } private actor AccountRegistryStore { @@ -1895,54 +2013,85 @@ private final class AccountRuntimeTransitionCoordinator { @MainActor private final class LoginSession { - let id = UUID() + typealias RootOperation = @MainActor @Sendable () async -> LoginRootObservation + typealias TerminationHandler = @MainActor @Sendable ( + LoginSession, + LoginTerminationReason, + LoginRootObservation + ) async -> LoginSessionTerminal + + private enum State { + case active + case closing( + reason: LoginTerminationReason, + completion: Task + ) + case closed(LoginSessionTerminal) + } + + let generationID: UUID + let purpose: LoginPurpose let handle: CodexLoginHandle let runtime: LoginRuntime - let activation: LoginActivation - let mutationLease: AccountRegistryStore.MutationLease - private var resultTask: Task? - private var didCompleteResult = false - private var didConsumeAccountUpdate: Bool - private var didCloseOwnedRuntime = false + private let mutationLease: AccountRegistryStore.MutationLease + private let rootOperation: RootOperation + private let terminationHandler: TerminationHandler + private var rootTask: Task? + private var state: State = .active + private var didTakeOwnedRuntime = false private var didReleaseMutationLease = false init( + generationID: UUID, + purpose: LoginPurpose, handle: CodexLoginHandle, runtime: LoginRuntime, - activation: LoginActivation, - mutationLease: AccountRegistryStore.MutationLease + mutationLease: AccountRegistryStore.MutationLease, + rootOperation: @escaping RootOperation, + terminationHandler: @escaping TerminationHandler ) { + self.generationID = generationID + self.purpose = purpose self.handle = handle self.runtime = runtime - self.activation = activation self.mutationLease = mutationLease - self.didConsumeAccountUpdate = runtime.usesPrimaryRuntime == false + self.rootOperation = rootOperation + self.terminationHandler = terminationHandler } - func installResultTask(_ task: Task) { - precondition(resultTask == nil, "A login session owns exactly one result task.") - resultTask = task - } - - func markResultCompleted() { - didCompleteResult = true - } - - func markAccountUpdateConsumed() { - didConsumeAccountUpdate = true + func activate() { + precondition(rootTask == nil, "A login session root task can be activated only once.") + let rootOperation = rootOperation + rootTask = Task { @MainActor in + await rootOperation() + } } - var isReadyForCleanup: Bool { - didCompleteResult && didConsumeAccountUpdate + func publishRootObservation(_: LoginRootObservation) { + guard case .active = state else { + return + } + _ = beginClosing(reason: .rootOutcome) } - var ownedRuntimeNeedsClose: Bool { - runtime.usesPrimaryRuntime == false && didCloseOwnedRuntime == false + func terminate(reason: LoginTerminationReason) async -> LoginSessionTerminal { + switch state { + case .active: + return await beginClosing(reason: reason).value + case .closing(_, let completion): + return await completion.value + case .closed(let terminal): + return terminal + } } - func markOwnedRuntimeClosed() { - precondition(runtime.usesPrimaryRuntime == false) - didCloseOwnedRuntime = true + func takeOwnedRuntimeForClose() -> LoginRuntime? { + guard runtime.usesPrimaryRuntime == false, + didTakeOwnedRuntime == false else { + return nil + } + didTakeOwnedRuntime = true + return runtime } func takeMutationLeaseForRelease() -> AccountRegistryStore.MutationLease? { @@ -1953,10 +2102,95 @@ private final class LoginSession { return mutationLease } - @discardableResult - func takeResultTask() -> Task? { - defer { resultTask = nil } - return resultTask + private func beginClosing( + reason: LoginTerminationReason + ) -> Task { + guard case .active = state else { + preconditionFailure("Only an active login session can begin termination.") + } + precondition(rootTask != nil, "A login session must be activated before 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 { + do { + _ = try await handle.cancel() + } 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() + } +} + +private enum LoginRootObservation: Sendable { + case outcome(CodexLoginOutcome) + case failure(CodexReviewAuthenticationFailure) + case waiterCancelled(message: String?) +} + +private 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 + } + } +} + +private enum LoginSessionTerminal: Equatable, Sendable { + case succeeded + case failed(CodexReviewAuthenticationFailure) + case cancelled + case stopped +} + +private enum LoginPurpose: Equatable, Sendable { + case signIn + case addAccountPreservingActive(String?) + + var activation: LoginActivation { + switch self { + case .signIn: + return .activateAuthenticatedAccount + case .addAccountPreservingActive(let activeAccountKey): + return .preserveActiveAccount(activeAccountKey) + } } } diff --git a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift b/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift deleted file mode 100644 index d0ba336..0000000 --- a/Sources/CodexReviewKit/Application/ReviewBackendEventSession.swift +++ /dev/null @@ -1,186 +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 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 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 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, .interrupted, .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, .interrupted, .failed, .cancelled: - true - case .started: - false - } - } -} diff --git a/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift b/Sources/CodexReviewKit/Application/ReviewObservationAwaiter.swift index 065ad04..de86b7a 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 2bb4dcb..0a11c4f 100644 --- a/Sources/CodexReviewKit/CodexReviewBackend.swift +++ b/Sources/CodexReviewKit/CodexReviewBackend.swift @@ -9,187 +9,71 @@ package protocol CodexReviewBackend: Sendable { 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, .interrupted, .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 977c94c..5107fff 100644 --- a/Sources/CodexReviewKit/CodexReviewTypes.swift +++ b/Sources/CodexReviewKit/CodexReviewTypes.swift @@ -159,17 +159,17 @@ package extension CodexReviewBackendModel.Auth { 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? @@ -182,68 +182,17 @@ 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 - } - } -} - -package extension CodexReviewBackendModel.Review { - struct Completion: Equatable, Sendable { - package var finalReview: String? - - package init(finalReview: String?) { - self.finalReview = finalReview?.nilIfEmpty + self.interruptedAttempt = interruptedAttempt } } } @@ -284,49 +233,116 @@ package struct ReviewTurnFailure: Codable, Hashable, Sendable { } } +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 enum ReviewBackendFailure: Error, Codable, Hashable, Sendable { - case message(String) - case missingReviewOutput(turnID: String) + case operation(ReviewBackendOperationFailure) + case missingReviewOutput(turnID: ReviewTurnID) + case outputPublication(ReviewOutputPublicationFailure) case invalidTerminalStatus( rawStatus: String, - turnID: 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 .message(let message): - message + 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 } } } -package extension CodexReviewBackendModel.Review { - enum Event: Equatable, Sendable { - case started(turnID: String, reviewThreadID: String?, model: String?) - case completed(CodexReviewBackendModel.Review.Completion) - case interrupted(message: String?) - case failed(ReviewBackendFailure) - case cancelled(String) - } -} - -package extension CodexReviewBackendModel.Review.Event { - static func completed(finalReview: String?) -> Self { - .completed(.init(finalReview: finalReview)) - } -} - package extension CodexReviewBackendModel { struct CancellationReason: Codable, Equatable, Sendable { package var message: String diff --git a/Sources/CodexReviewKit/Model/ReviewIdentity.swift b/Sources/CodexReviewKit/Model/ReviewIdentity.swift new file mode 100644 index 0000000..daff1d2 --- /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 4cbbced..d535141 100644 --- a/Sources/CodexReviewKit/Model/ReviewRunCore.swift +++ b/Sources/CodexReviewKit/Model/ReviewRunCore.swift @@ -1,74 +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 failure: ReviewBackendFailure? + 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, - failure: ReviewBackendFailure? = nil - ) { - self.status = status - self.exitCode = exitCode - self.startedAt = startedAt - self.endedAt = endedAt - self.cancellation = cancellation - self.errorMessage = errorMessage - self.failure = failure + 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 0000000..cb69f60 --- /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 acb522d..e17241f 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 0671518..845081d 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, @@ -19,34 +21,89 @@ extension ReviewRunRecord { 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, - failure: failure - ?? (status == .failed ? .message(errorMessage ?? summary) : nil) - ), - 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, @@ -58,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 0000000..ef40a8c --- /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 f3f05e9..eab4275 100644 --- a/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift +++ b/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift @@ -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 dc3cd9b..61ff41a 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 9867fb4..3403ff2 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -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,7 +75,7 @@ public final class CodexReviewStore { isolated deinit { accountRateLimitAutoRefreshDriver?.cancel() - runtimeState.cancelAllWorkers() + runtimeState.signalCancellation() } public static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore { @@ -73,10 +84,14 @@ public final class CodexReviewStore { 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,7 +112,8 @@ public final class CodexReviewStore { clock: clock, idGenerator: idGenerator, networkMonitor: networkMonitor, - networkRecoveryPolicy: networkRecoveryPolicy + networkRecoveryPolicy: networkRecoveryPolicy, + reviewThreadRetentionJournal: reviewThreadRetentionJournal ) } @@ -120,22 +137,38 @@ public final class CodexReviewStore { } 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 stop(purpose: .runtimeRestartPreservingRuns) await start(forceRestartIfNeeded: true) } @@ -357,8 +390,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( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index 474a3ed..f10b969 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -28,10 +28,12 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { var seed: CodexReviewStoreSeed { get } var isActive: Bool { get } var invokesRuntimeStopReviewCleanupDuringStop: Bool { get } + var reviewThreadRetentionCodexHomePath: String { get } + var reviewThreadRetentionJournalURL: URL? { get } func attachStore(_ store: CodexReviewStore) 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 throws @@ -45,25 +47,46 @@ 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 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 } } @@ -88,4 +111,14 @@ extension CodexReviewStoreBackend { 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 721eac2..26812b5 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -1,100 +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.lifecycle.failure = nil - 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, @@ -119,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 } @@ -135,51 +137,33 @@ 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 startingRunIDs: [String] = orderedReviewRuns.compactMap { runRecord in - guard runRecord.isTerminal == false, - runtimeState.isStarting(runRecord.id) - else { - return nil - } - return runRecord.id - } + let activeRunIDs = Set( + orderedReviewRuns + .filter { $0.isTerminal == false } + .map(\.id) + ) markActiveReviewCancellationsPendingForRuntimeStop(reason: reason) let request = runtimeStopReviewCleanupRequest(reason: reason) let didCompleteInitialBackendCleanup = await cleanupBackendReviews(request) - _ = await drainReviewWorkersForRuntimeStop( - runIDs: startingRunIDs, - timeout: workerDrainTimeout - ) - let locallyCancelledReviewRunIDs = cancelActiveReviewsLocallyForRuntimeStop( + _ = 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: didCompleteInitialBackendCleanup && didCompleteFinalBackendCleanup, @@ -192,9 +176,8 @@ extension CodexReviewStore { ) { for runRecord in orderedReviewRuns where runRecord.isTerminal == false { runRecord.cancellationRequested = true - runRecord.core.lifecycle.cancellation = reason - runRecord.core.lifecycleMessage = reason.message - runRecord.core.lifecycle.errorMessage = reason.message + runRecord.pendingCancellation = reason + runRecord.executionPhase = .cancelling(reason) } } @@ -203,7 +186,7 @@ extension CodexReviewStore { ) -> CodexReviewRuntimeStopReviewCleanupRequest { return .init( reason: .init(message: reason.message), - recoveryWaitingRuns: runtimeState.recoveryWaitingRuns() + recoveryWaitingAttempts: runtimeState.recoveryWaitingAttempts() ) } @@ -211,7 +194,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 } @@ -222,11 +205,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) @@ -235,64 +214,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 drainReviewWorkersForRuntimeStop( - runIDs: [String], - timeout: Duration - ) async -> Bool { - let tasks = runtimeState.activeWorkerTasks(for: runIDs) - 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 @@ -300,21 +221,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.core.lifecycle.errorMessage = - resolvedError - ?? reason.nilIfEmpty - ?? runRecord.core.lifecycle.errorMessage - runRecord.core.lifecycle.failure = .message( - runRecord.core.lifecycle.errorMessage ?? runRecord.core.lifecycleMessage + runRecord.pendingCancellation = nil + let failure = ReviewBackendFailure.connectionTerminated( + .transport( + message: resolvedError ?? reason.nilIfEmpty ?? "Failed to cancel review." + ) ) - runRecord.core.lifecycle.endedAt = clock.now() + 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.executionPhase = nil } noteReviewRunMutation() } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreOrderQueries.swift index 548372c..82f82fa 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 fc99ac2..dca81e1 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,99 @@ extension CodexReviewStore { } @discardableResult - private func beginReview( + package func beginReview( sessionID: String, request: CodexReviewAPI.Start.Request - ) throws -> String { + ) async throws -> ReviewRunID { + try await requireReviewThreadRetentionAcceptance() 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 +167,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 +190,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 +235,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,46 +358,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 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, - workerDrainTimeout: Duration + reason: ReviewCancellation ) async -> Bool { let runIDs = orderedReviewRuns .filter { $0.isTerminal == false } .map(\.id) - for runID in runIDs { - do { - _ = try await cancelReview(runID: runID, cancellation: reason) - } catch { + _ = 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 await drainReviewWorkersForRuntimeStop(timeout: workerDrainTimeout) + 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 @@ -532,16 +506,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))) } @@ -550,352 +524,7 @@ extension CodexReviewStore { writeDiagnosticsIfNeeded() } - private func markReviewRunning(_ runRecord: ReviewRunRecord, startedAt: Date) { - runRecord.core.lifecycle.status = .running - runRecord.core.lifecycle.startedAt = startedAt - runRecord.core.lifecycle.failure = nil - runRecord.core.lifecycleMessage = "Review started." - runRecord.core.finalReview = nil - writeDiagnosticsIfNeeded() - } - - private func markReviewFailed(_ runRecord: ReviewRunRecord, message: String) { - markReviewFailed(runRecord, failure: .message(message)) - } - - private func markReviewFailed( - _ runRecord: ReviewRunRecord, - failure: ReviewBackendFailure - ) { - guard runRecord.isTerminal == false else { - return - } - let endedAt = clock.now() - let message = failure.message - runRecord.core.lifecycle.status = .failed - runRecord.core.lifecycle.endedAt = endedAt - runRecord.core.lifecycle.errorMessage = message - runRecord.core.lifecycle.failure = failure - 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 .interrupted(let message): - markReviewFailed( - runRecord, - failure: .interruptedByBackend(message: message) - ) - case .failed(let failure): - markReviewFailed(runRecord, failure: failure) - 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.lifecycle.errorMessage = nil - runRecord.core.lifecycle.failure = nil - 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 { @@ -912,516 +541,20 @@ extension CodexReviewStore { } } -private extension CodexReviewBackendModel.Review.Event { - var completesReviewRun: Bool { - switch self { - case .completed, .interrupted, .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() - } - } -} - -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 +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 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 fe86ab1..e6aeb7c 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreRuntimeState.swift @@ -1,166 +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 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 activeWorkerTasks(for runIDs: [String]) -> [Task] { - runIDs.compactMap { reviewWorkerTasks[$0] } + func cancellationOperationFinished( + for runID: ReviewRunID, + token: ReviewCancellationOperationToken + ) { + guard cancellationOperations[runID]?.join.token == token else { + return + } + cancellationOperations.removeValue(forKey: runID) } - func clearRuntimeStopState(for runID: String) { - removeActiveRun(for: runID) - clearWaitingForNetworkRecovery(runID) - clearStarting(runID) - _ = takeStartupCancellation(for: runID) + func cancellationOperationTasks() -> [Task] { + cancellationOperations.values.map(\.join.task) } - func clearReviewRunState(for runID: String) { - removeActiveRun(for: runID) + 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 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/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift index cb29421..4b94a5f 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 @@ -137,9 +156,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 +169,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 0000000..3bfe523 --- /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 0000000..f093a26 --- /dev/null +++ b/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift @@ -0,0 +1,1164 @@ +import Foundation + +package struct ReviewWorkerState: Sendable { + package struct Outage: Sendable { + package let epoch: UInt64 + package let observedAt: ContinuousClock.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: ContinuousClock.Instant + package let presentationDate: Date + + init(_ snapshot: CodexReviewNetworkSnapshot) { + switch snapshot.status { + case .satisfied: + connectivity = .satisfied + case .unsatisfied, .requiresConnection: + connectivity = .outage + } + observedAt = ContinuousClock().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, 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, 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, generation: generation) + case (.pendingOutage, .outage): + addNetworkChild(to: &group, source: networkSource, 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, 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, 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, generation: generation) + case (.settling, .satisfied): + addNetworkChild(to: &group, source: source, 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, + generation: UInt64 +) { + group.addTask { + guard let snapshot = await source.next() else { + return .networkSourceFinished(generation: generation) + } + return .networkSnapshot( + generation: generation, + ReviewWorkerConnectivitySnapshot(snapshot) + ) + } +} + +private func addOutageTimer( + to group: inout TaskGroup, + policy: CodexReviewNetworkRecoveryPolicy, + generation: UInt64, + outageEpoch: UInt64 +) { + group.addTask { + do { + try await policy.sleep(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.sleep(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 0000000..a195ffe --- /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/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPHTTPServer.swift index 529e007..6571277 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 14d5e61..1c6fe42 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 12ca0dc..d2bff44 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -9,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 { @@ -36,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) } @@ -88,25 +88,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, @@ -117,6 +115,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, @@ -130,12 +129,14 @@ package final class CodexReviewMCPServer { sessionID: sessionID, cwd: cwd, statuses: statuses, - limit: limit + limit: limit, + allowedRunIDs: allowedRunIDs )) 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( runID: runRecord.id, @@ -144,6 +145,38 @@ package final class CodexReviewMCPServer { } } + 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) + } + } + private func reviewSnapshot( _ result: CodexReviewAPI.Read.Result, sessionID: String? @@ -151,12 +184,51 @@ 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 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 { @@ -169,39 +241,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 32e991a..23ce60b 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 dd22981..91799b0 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" } @@ -65,7 +65,7 @@ private extension CodexReviewAPI.Read.Result { structuredContent( includeLog: true, includeDetails: false, - includeNextAction: core.lifecycle.status.isTerminal == false, + includeNextAction: presentation.status.isTerminal == false, log: log ) } @@ -86,14 +86,15 @@ private extension CodexReviewAPI.Read.Result { 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 { @@ -105,7 +106,7 @@ private extension CodexReviewAPI.Read.Result { if includeNextAction { object["nextAction"] = .object([ "tool": .string(CodexReviewMCP.Tool.Name.reviewAwait.rawValue), - "runId": .string(runID), + "runId": .string(runID.rawValue), ]) } return .object(object) @@ -203,7 +204,7 @@ private extension ReviewMCPLogProjection { private extension ReviewMCPLogProjection.Item { func structuredContent() -> Value { - .object([ + return .object([ "id": .string(id), "kind": .string(kind), "content": content.structuredContent(), @@ -349,13 +350,14 @@ private extension String { private extension CodexReviewAPI.Run.ListItem { func structuredContent() -> Value { .object([ - "runId": .string(runID), + "runId": .string(runID.rawValue), "cwd": .string(cwd), "targetSummary": .string(targetSummary), - "run": core.run.structuredContent(), - "lifecycle": core.structuredLifecycleContent( + "run": core.structuredRunContent(), + "lifecycle": presentation.structuredLifecycleContent( + core: core, elapsedSeconds: elapsedSeconds, - cancellable: cancellable + cancellable: presentation.isCancellable ), "review": core.structuredReviewContent(), ]) @@ -373,55 +375,72 @@ private extension CodexReviewAPI.List.Result { private extension CodexReviewAPI.Cancel.Outcome { func textContent() -> String { if cancelled { - core.lifecycle.cancellation?.message ?? "Review cancelled." + presentation.cancellation?.message ?? "Review cancelled." } else { "Review was already finished." } } func structuredContent() -> Value { - .object([ - "runId": .string(runID), + return .object([ + "runId": .string(runID.rawValue), "cancelled": .bool(cancelled), - "run": core.run.structuredContent(), - "lifecycle": core.structuredLifecycleContent( + "run": core.structuredRunContent(), + "lifecycle": presentation.structuredLifecycleContent( + core: core, elapsedSeconds: nil, - cancellable: false + cancellable: presentation.isCancellable ), "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, - "failure": failure.map { $0.structuredContent() } ?? .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) } } @@ -431,15 +450,19 @@ private extension ReviewBackendFailure { "message": .string(message), ] switch self { - case .message: - object["kind"] = .string("message") + case .operation(let failure): + object["kind"] = .string("operation") + object["operation"] = failure.structuredContent() case .missingReviewOutput(let turnID): object["kind"] = .string("missingReviewOutput") - object["turnId"] = .string(turnID) + 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) + object["turnId"] = .string(turnID.rawValue) object["turnFailure"] = turnFailure.map { $0.structuredContent() } ?? .null case .turnFailed(let turnFailure): object["kind"] = .string("turnFailed") @@ -447,11 +470,132 @@ private extension ReviewBackendFailure { 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([ @@ -512,26 +656,51 @@ private extension ReviewTurnFailure.Code { } 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 0000000..1e15751 --- /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 0000000..980b990 --- /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 c4ff1a6..54d4004 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -31,6 +31,7 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { var activeEntryIDs: [String] var activeEntryCount: Int var latestEntryID: String? + var turnID: CodexTurnID? var finalLifecycleMessage: String? var finalResult: String? var items: [Item] @@ -40,12 +41,13 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } private init(result: CodexReviewAPI.Read.Result) { - self.revision = "\(result.runID):unavailable" + self.revision = "\(result.runID.rawValue):unavailable" self.items = [] self.orderedEntryIDs = [] self.activeEntryIDs = [] self.activeEntryCount = activeEntryIDs.count self.latestEntryID = orderedEntryIDs.last + self.turnID = nil self.finalLifecycleMessage = nil self.finalResult = nil } @@ -53,11 +55,10 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { 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 @@ -76,9 +77,9 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } .joined(separator: "|") self.revision = [ - result.runID, + result.runID.rawValue, status.rawValue, - lifecycle.endedAt?.timeIntervalSince1970.description ?? "running", + result.core.endedAt?.timeIntervalSince1970.description ?? "running", turnID.rawValue, itemRevision, ].joined(separator: ":") @@ -87,22 +88,19 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { 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.finalLifecycleMessage = status.isTerminal ? result.presentation.lifecycle.message : nil 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 static func nonEmptyReviewOutput(_ value: String) -> String? { + guard value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return nil + } + return value } } @@ -120,13 +118,6 @@ private extension String { } 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 5206e1b..7310e76 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 44734d2..bdc40c3 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,6 +137,128 @@ 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 @@ -119,53 +266,64 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { case readAuth 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 nextAttempt: ReviewAttempt + private var nextRecoveredAttempt: 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") + nextAttempt: ReviewAttempt = makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + activeTurnThreadID: "review-thread-1", + turnID: "turn-1" + ) ) { self.settings = settings self.auth = auth - self.nextRun = nextRun + self.nextAttempt = nextAttempt } package func recordedCommands() -> [Command] { @@ -174,6 +332,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) { @@ -184,16 +348,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 setNextRecoveredAttempt(_ attempt: ReviewAttempt) { + nextRecoveredAttempt = attempt + } + + package func setNextAttempt(_ attempt: ReviewAttempt) { + nextAttempt = attempt } - package func setNextRecoveredRun(_ run: CodexReviewBackendModel.Review.Run) { - nextRecoveredRun = run + package func setDiscardedRestartAttempts(_ attempts: [ReviewAttempt]) { + discardedRestartAttempts = attempts } package func waitForStartReview() async { @@ -235,28 +451,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: { @@ -272,6 +484,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 { @@ -348,6 +569,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 @@ -388,22 +641,28 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { waiter.resume() } if let startReviewGate { - await startReviewGate.wait() + if startReviewIgnoresCancellation { + await startReviewGate.waitIgnoringCancellation() + } else { + try await startReviewGate.wait() + } } - return .init(run: nextRun, events: eventMailbox(for: nextRun)) + return backendAttempt(for: nextAttempt) } 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) @@ -411,21 +670,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( @@ -433,68 +700,143 @@ 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( + let interruptedAttempt = token.interruptedAttempt + let recoveredAttempt = + nextRecoveredAttempt + ?? makeReviewAttemptForTesting( attemptID: "attempt-recovered", - threadID: run.threadID, + sourceThreadID: interruptedAttempt.threadIdentity.sourceThreadID.rawValue, + activeTurnThreadID: interruptedAttempt.threadIdentity.activeTurnThreadID.rawValue, turnID: "turn-recovered", - reviewThreadID: run.reviewThreadID, - model: run.model ?? request.model + model: interruptedAttempt.model ?? request.model ) - return .init(run: recoveredRun, events: eventMailbox(for: recoveredRun)) + preparedRestartTokens.removeValue(forKey: token.id) + return backendAttempt(for: recoveredAttempt) + } + + 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 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 cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async { - commands.append(.cleanupReview(run)) + 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 yield( - _ event: CodexReviewBackendModel.Review.Event, for run: CodexReviewBackendModel.Review.Run? = nil + _ terminal: FakeReviewTerminal, for attempt: ReviewAttempt? = nil ) async { - await eventMailbox(for: run ?? nextRun).append(event) + await terminalSource(for: attempt ?? nextAttempt).yield(terminal) } - package func finishEvents(for run: CodexReviewBackendModel.Review.Run? = nil) async { - await eventMailbox(for: run ?? nextRun).finish() + package func finishEvents(for attempt: ReviewAttempt? = nil) async { + await terminalSource(for: attempt ?? nextAttempt).finish() } - package func finishEvents(throwing error: any Error, for run: CodexReviewBackendModel.Review.Run? = nil) async { - await eventMailbox(for: run ?? nextRun).fail(error) + package func finishEvents(throwing error: any Error, for attempt: ReviewAttempt? = nil) async { + await terminalSource(for: attempt ?? nextAttempt).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) { @@ -502,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) { @@ -513,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 @@ -527,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 ) } @@ -561,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 } } @@ -599,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 @@ -633,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 } @@ -736,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( @@ -755,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/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index ee18a96..66ac1d9 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -1,10 +1,17 @@ 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 @@ -12,6 +19,7 @@ struct ReviewMonitorCodexChatLogProjection { mutating func reset() { documentProjection.reset() + reportedMissingRolloutTargetSourceIDs.removeAll(keepingCapacity: false) } mutating func render( @@ -79,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?, @@ -89,37 +155,23 @@ 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 turnPolicy = ReviewTurnPresentationPolicy(items: presentationFacts) + let rolloutPolicy = ReviewRolloutPresentationPolicy().evaluate(presentationFacts) + 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( @@ -127,18 +179,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 [ @@ -256,71 +301,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 turn: \(sourceID, privacy: .public)" + ) } - return payload.kindValue } private func unknownText(title: String, detail: String?) -> String { @@ -606,74 +606,6 @@ struct ReviewMonitorCodexChatLogProjection { } } -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 } @@ -682,8 +614,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 { @@ -733,13 +666,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 @@ -775,27 +713,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.kind.rawValue):\(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 3f94228..063de2b 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -40,12 +40,15 @@ struct ReviewMonitorCodexChatLogSourceProjection { private var snapshot: CodexChatObservationSnapshot? private var cursor: Cursor? private var hasLogDocument = false + private var projectedBlocksByLocator: + [CodexChatItemLocator: [ReviewMonitorLogProjectedBlock]] = [:] mutating func reset() { logProjection.reset() snapshot = nil cursor = nil hasLogDocument = false + projectedBlocksByLocator.removeAll(keepingCapacity: false) } mutating func apply( @@ -55,13 +58,14 @@ struct ReviewMonitorCodexChatLogSourceProjection { 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 renderSnapshot(allowIncrementalUpdate: false) + return renderCachedProjection(allowIncrementalUpdate: false) case .update(let update): validateUpdateCursor(event) apply(update) cursor = .init(generation: event.generation, sequence: event.sequence) - return renderSnapshot(allowIncrementalUpdate: hasLogDocument) + return renderCachedProjection(allowIncrementalUpdate: hasLogDocument) } } @@ -99,61 +103,280 @@ struct ReviewMonitorCodexChatLogSourceProjection { precondition(turns.contains { $0.id == turn.id } == false) precondition(turns.indices.contains(index) || index == turns.endIndex) turns.insert(turn, at: index) + projectWholeTurn(turn, phase: snapshot.phase) 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) + if previous.status != turn.status { + reprojectStatusDependentItems(in: turn, phase: snapshot.phase) + } case .turnRemoved(let id): let index = requiredUniqueIndex(in: turns) { $0.id == id } - turns.remove(at: index) + let removed = turns.remove(at: index) + for item in removed.items { + projectedBlocksByLocator.removeValue( + forKey: locator(for: item, turnID: removed.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) + let previous = turns[turnIndex] precondition( turns[turnIndex].items.indices.contains(index) || index == turns[turnIndex].items.endIndex ) turns[turnIndex].items.insert(item, at: index) + reconcileItemMutation( + previous: previous, + current: turns[turnIndex], + explicitlyChanged: [locator(for: item, turnID: turnID)], + removed: [], + phase: snapshot.phase + ) case .itemUpdated(let item, let turnID, let index): let turnIndex = requiredTurnIndex(turnID, in: turns) + let previous = turns[turnIndex] let previousIndex = requiredUniqueIndex(in: turns[turnIndex].items) { itemMatches($0, id: item.id, kind: item.kind) } - turns[turnIndex].items.remove(at: previousIndex) + 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) + reconcileItemMutation( + previous: previous, + current: turns[turnIndex], + explicitlyChanged: [currentLocator], + removed: previousLocator == currentLocator ? [] : [previousLocator], + phase: snapshot.phase + ) case .itemRemoved(let locator): let turnIndex = requiredTurnIndex(locator.turnID, in: turns) + let previous = turns[turnIndex] let itemIndex = requiredUniqueIndex(in: turns[turnIndex].items) { itemMatches($0, id: locator.id, kind: locator.kind) } turns[turnIndex].items.remove(at: itemIndex) + reconcileItemMutation( + previous: previous, + current: turns[turnIndex], + explicitlyChanged: [], + removed: [locator], + phase: snapshot.phase + ) case .itemTextAppended(let locator, let delta): let turnIndex = requiredTurnIndex(locator.turnID, in: turns) + let previous = turns[turnIndex] let itemIndex = requiredUniqueIndex(in: turns[turnIndex].items) { itemMatches($0, id: locator.id, kind: locator.kind) } append(delta, to: &turns[turnIndex].items[itemIndex]) + reconcileItemMutation( + previous: previous, + current: turns[turnIndex], + explicitlyChanged: [locator], + removed: [], + phase: snapshot.phase + ) 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) + reprojectStatusDependentItems(in: turns[turnIndex], phase: phase) + } } snapshot.thread.turns = turns self.snapshot = snapshot } + private mutating func replaceProjectionCache( + with snapshot: CodexChatObservationSnapshot + ) { + projectedBlocksByLocator.removeAll(keepingCapacity: true) + for turn in snapshot.thread.turns ?? [] { + projectWholeTurn(turn, phase: snapshot.phase) + } + } + + private mutating func projectWholeTurn( + _ turn: CodexTurnSnapshot, + phase: CodexChatPhase + ) { + var seen: Set = [] + let presentation = presentationState(for: turn, phase: phase, reportMissing: true) + 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 reconcileItemMutation( + previous: CodexTurnSnapshot, + current: CodexTurnSnapshot, + explicitlyChanged: Set, + removed: Set, + phase: CodexChatPhase + ) { + precondition(previous.id == current.id) + let previousPresentation = presentationState( + for: previous, + phase: phase, + reportMissing: false + ) + let currentPresentation = presentationState( + for: current, + phase: phase, + reportMissing: true + ) + + for locator in removed { + precondition(projectedBlocksByLocator.removeValue(forKey: locator) != nil) + } + + var locatorsToReproject = explicitlyChanged + if previousPresentation.hidesUserMessages != currentPresentation.hidesUserMessages { + for item in current.items where item.isUserMessage { + locatorsToReproject.insert(locator(for: item, turnID: current.id)) + } + } + + let changedSuppressionSourceIDs = + previousPresentation.suppressedRolloutSourceIDs + .symmetricDifference(currentPresentation.suppressedRolloutSourceIDs) + if changedSuppressionSourceIDs.isEmpty == false { + for item in current.items { + let facts = logProjection.presentationFacts( + for: item, + turnID: current.id, + turnStatus: projectedStatus(for: current, phase: phase) + ) + if changedSuppressionSourceIDs.contains(facts.sourceID) { + locatorsToReproject.insert(locator(for: item, turnID: current.id)) + } + } + } + + for locator in locatorsToReproject { + let itemIndex = requiredUniqueIndex(in: current.items) { + itemMatches($0, id: locator.id, kind: locator.kind) + } + reproject( + current.items[itemIndex], + in: current, + phase: phase, + presentation: currentPresentation + ) + } + } + + private mutating func reprojectStatusDependentItems( + in turn: CodexTurnSnapshot, + phase: CodexChatPhase + ) { + let presentation = presentationState(for: turn, phase: phase, reportMissing: true) + for item in turn.items where logProjection.dependsOnTurnStatus(item) { + reproject(item, in: turn, phase: phase, presentation: presentation) + } + } + + private mutating func reproject( + _ item: CodexThreadItem, + in turn: CodexTurnSnapshot, + phase: CodexChatPhase, + presentation: TurnPresentationState + ) { + 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.hidesUserMessages, + suppressRolloutCompanion: + presentation.suppressedRolloutSourceIDs.contains(facts.sourceID) + ) + } + + private mutating func presentationState( + for turn: CodexTurnSnapshot, + phase: CodexChatPhase, + reportMissing: Bool + ) -> TurnPresentationState { + let status = projectedStatus(for: turn, phase: phase) + let facts = 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( + hidesUserMessages: ReviewTurnPresentationPolicy(items: facts) + .hidesUserMessage(in: turn.id), + suppressedRolloutSourceIDs: rollout.suppressedCompanionSourceIDs + ) + } + + 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] @@ -188,6 +411,8 @@ struct ReviewMonitorCodexChatLogSourceProjection { 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) } } @@ -246,16 +471,22 @@ struct ReviewMonitorCodexChatLogSourceProjection { } } - private mutating func renderSnapshot( + private mutating func renderCachedProjection( allowIncrementalUpdate: Bool ) -> ReviewMonitorLogSourceChange? { - guard let snapshot, - let document = logProjection.render( - from: snapshot.thread, - chatCreatedAt: snapshot.thread.createdAt, - chatUpdatedAt: snapshot.thread.updatedAt - ) - else { + 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() } @@ -277,4 +508,18 @@ struct ReviewMonitorCodexChatLogSourceProjection { hasLogDocument = false return .clear } + + private struct TurnPresentationState { + var hidesUserMessages: Bool + var suppressedRolloutSourceIDs: Set + } +} + +private extension CodexThreadItem { + var isUserMessage: Bool { + guard case .message(let message) = content else { + return false + } + return message.role == .user + } } diff --git a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift new file mode 100644 index 0000000..d9be3b0 --- /dev/null +++ b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift @@ -0,0 +1,90 @@ +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 markedTurnIDs: Set + + init(items: [ReviewItemPresentationFacts]) { + markedTurnIDs = Set(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 markedTurnIDs.contains(turnID) + } +} + +struct ReviewRolloutPresentationPolicy: Sendable { + struct Result: Equatable, Sendable { + var suppressedCompanionSourceIDs: Set + var missingTargetSourceIDs: Set + } + + func evaluate(_ items: [ReviewItemPresentationFacts]) -> Result { + let targetTextsByTurnID = Dictionary(grouping: items.compactMap { item -> Target? in + guard item.kind == .exitedReviewMode, let turnID = item.turnID else { + return nil + } + return Target(turnID: turnID, normalizedText: normalized(item.displayText)) + }, by: \.turnID) + + var suppressed: Set = [] + var missingTargets: Set = [] + for item in items where isReviewRolloutCompanion(item) { + guard let turnID = item.turnID, + let targets = targetTextsByTurnID[turnID], + targets.isEmpty == false + else { + missingTargets.insert(item.sourceID) + continue + } + guard let companionText = normalized(item.displayText) else { + continue + } + if targets.contains(where: { $0.normalizedText == companionText }) { + suppressed.insert(item.sourceID) + } + } + return .init( + suppressedCompanionSourceIDs: suppressed, + missingTargetSourceIDs: missingTargets + ) + } + + private func isReviewRolloutCompanion(_ item: ReviewItemPresentationFacts) -> Bool { + item.kind == .agentMessage + && item.origin == .reviewRolloutAssistant + && item.semanticRelation == .companionOf(.exitedReviewMode) + } + + private func normalized(_ text: String?) -> String? { + guard let value = text?.trimmingCharacters(in: .whitespacesAndNewlines), + value.isEmpty == false + else { + return nil + } + return value + } + + private struct Target: Sendable { + var turnID: CodexTurnID + var normalizedText: String? + } +} diff --git a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift index d68d465..7c65d49 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexSelectionTitleResolver.swift @@ -20,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/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift index 1753328..ceee4e7 100644 --- a/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift +++ b/Sources/ReviewUI/Sidebar/CodexChats/ReviewMonitorSidebarViewController.swift @@ -98,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() @@ -283,10 +283,10 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD ) } - private static func makeCodexSidebarFetchedResultsController( + private static func makeCodexSidebarFetchedResults( modelContext: CodexModelContext - ) -> CodexFetchedResultsController { - modelContext.fetchedResultsController( + ) -> CodexFetchedResults { + modelContext.fetchedResults( for: defaultCodexSidebarDescriptor, sectionedBy: .workspaceGroup ) @@ -306,7 +306,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD if let modelContext, let codexSidebarModelContext, codexSidebarModelContext === modelContext, - codexSidebarResultsController != nil + codexSidebarResults != nil { return } @@ -316,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( @@ -375,7 +375,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } private var codexSidebarSourceSections: [CodexFetchSection] { - codexSidebarResultsController?.sections ?? [] + codexSidebarResults?.sections ?? [] } private func codexSidebarVisibleSections( @@ -1551,7 +1551,7 @@ final class ReviewMonitorSidebarViewController: NSViewController, NSOutlineViewD } var codexSidebarSectionsForTesting: [CodexFetchSection] { - codexSidebarResultsController?.sections ?? [] + codexSidebarResults?.sections ?? [] } var codexSidebarRootTitlesForTesting: [String] { @@ -1637,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/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift new file mode 100644 index 0000000..3ed5994 --- /dev/null +++ b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift @@ -0,0 +1,473 @@ +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 + } + await eventSink.emit(work.notification, using: runtime) + 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 b148b12..902fc41 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -23,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 = [] @@ -47,36 +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, @@ -86,47 +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) - ) - } - - isolated deinit { - startTask?.cancel() - streamTask?.cancel() - notificationTask?.cancel() + do { + self.threadStore = try CodexAppServerTestThreadStore( + threads: fixtures.map(\.storedThread) + ) + } catch { + preconditionFailure("Invalid Preview thread store: \(error)") + } } var initialChatID: CodexThreadID? { @@ -141,167 +174,71 @@ final class ReviewMonitorPreviewAppServerRuntime { } func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { - await threadStore.snapshot(id: 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(method: "turn/interrupt").count + await threadStore.storedThread(id: chatID)?.snapshot } 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( + func prepareUpsertPreviewItem( _ item: CodexAppServerTestItem, to chatID: CodexThreadID - ) async { + ) async -> ReviewMonitorPreviewRuntimeNotification? { guard let fixture = fixturesByChatID[chatID] else { - return + return nil } guard let storedItem = await upsertStoredItem(item, in: fixture) else { - return - } - start() - enqueueNotification { [weak self] in - await self?.emitItemLifecycle(storedItem, for: fixture) + 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, 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 { - preconditionFailure("Failed to append Preview text: \(error)") - } - } - } - - 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 { - preconditionFailure("Failed to start the Preview app-server runtime: \(error)") - } - } - } - - 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 + ) } - func cancelStreaming() { - streamTask?.cancel() - } - - func stopStreaming() async { - let task = streamTask - streamTask = nil - task?.cancel() - await task?.value - await notificationTask?.value - } - - @discardableResult - func appendPreviewStreamTick( - after currentTick: Int = 0, - emitsNotifications: Bool = false - ) async -> Int { + 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 archivedChatIDs.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 { - preconditionFailure("Failed to start the Preview app-server runtime while streaming: \(error)") - } + return .init(tick: currentTick, notifications: []) } let nextTick = currentTick + 1 + var notifications: [ReviewMonitorPreviewRuntimeNotification] = [] for (index, fixture) in runningFixtures { guard let frame = ReviewMonitorPreviewContent.streamFrame( @@ -314,91 +251,83 @@ final class ReviewMonitorPreviewAppServerRuntime { 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) - } - } + notifications.append(.stream(frame.step, storedItem, fixture)) } - tick = nextTick - return nextTick + self.currentTick = nextTick + return .init(tick: nextTick, notifications: notifications) } - private func ensureStarted() async throws { - if runtime != nil { - return - } - if let startTask { - await startTask.value - return - } - try await startNow() - } - - 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.state.isTerminalForPreview == false { - turn.state = .interrupted + 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 { + switch notification { + case .itemLifecycle(let storedItem, let fixture): + await emitItemLifecycle(storedItem, for: fixture, using: runtime) + case .textDelta(let delta, let itemID, let turnID, let chatID, let kind, let content): + do { + try await emitTextDelta( + delta, + itemID: itemID, + turnID: turnID, + chatID: chatID, + kind: kind, + content: content, + runtime: runtime + ) + } catch { + preconditionFailure("Failed to append Preview text: \(error)") + } + case .stream(let step, let storedItem, let fixture): + await emit(step, storedItem: storedItem, for: fixture, using: runtime) + case .cancelled(let storedThread, let fixture): + await emitCancelledState(storedThread, for: fixture, using: runtime) } } private func emit( _ step: ReviewMonitorPreviewContent.PreviewChatLogStreamStep, storedItem: ReviewMonitorPreviewStoredThreadItem, - for fixture: ReviewMonitorPreviewChatLogFixture + for fixture: ReviewMonitorPreviewChatLogFixture, + using runtime: CodexAppServerTestRuntime ) async { - guard let runtime else { - return - } do { switch step.mode { case .textDelta: @@ -415,8 +344,6 @@ final class ReviewMonitorPreviewAppServerRuntime { guard let fixtureItem = storedItem.fixtureItem else { preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") } - await runtime.transport.waitForNotificationStreamCount(1) - await runtime.transport.waitForRequest(method: "thread/read") switch step.mode { case .update: try await runtime.notificationEmitter.emitItemStarted( @@ -477,23 +404,34 @@ final class ReviewMonitorPreviewAppServerRuntime { _ 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 = fixtureItem.domainProjection - 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, - fixtureItem: fixtureItem - ) } } @@ -506,38 +444,53 @@ final class ReviewMonitorPreviewAppServerRuntime { 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.") } + 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: turnID, - fixtureItem: nil + turnID: turns[turnIndex].snapshot.id, + fixtureItem: fixtureItem ) + } catch { + preconditionFailure("Failed to append Preview stored text: \(error)") } - preconditionFailure("A preview text delta requires a previously started item.") } } private func emitItemLifecycle( _ storedItem: ReviewMonitorPreviewStoredThreadItem, - for fixture: ReviewMonitorPreviewChatLogFixture + for fixture: ReviewMonitorPreviewChatLogFixture, + using runtime: CodexAppServerTestRuntime ) async { + guard let fixtureItem = storedItem.fixtureItem else { + preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") + } do { - try await ensureStarted() - guard let runtime, let fixtureItem = storedItem.fixtureItem else { - return - } - await runtime.transport.waitForNotificationStreamCount(1) - await runtime.transport.waitForRequest(method: "thread/read") if storedItem.item.isTerminalPreviewItem { try await runtime.notificationEmitter.emitItemCompleted( threadID: fixture.chatID, @@ -556,78 +509,6 @@ final class ReviewMonitorPreviewAppServerRuntime { } } - private func updateStoredSnapshot( - for fixture: ReviewMonitorPreviewChatLogFixture, - _ mutation: @escaping @Sendable (inout CodexThreadSnapshot) -> ReviewMonitorPreviewStoredThreadItem? - ) async -> ReviewMonitorPreviewStoredThreadItem? { - await snapshotMutationQueue.run { @MainActor [weak self] in - guard let self, - await self.cancelledChatIDs.contains(fixture.chatID) == false, - await self.archivedChatIDs.contains(fixture.chatID) == false, - var snapshot = await self.threadStore.snapshot(id: fixture.chatID), - let item = mutation(&snapshot) - else { - return nil - } - await self.replaceThreadStorePreservingFixtureOrder( - with: fixture.threadSnapshot(snapshot) - ) - return item - } - } - - private func updateStoredSnapshot( - 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 - ) - } - 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 { - preconditionFailure("Failed to rebind the Preview thread store: \(error)") - } - } - private func emitTextDelta( _ delta: String, itemID: String, @@ -640,8 +521,6 @@ 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) { if case .reasoning(let reasoning) = content, reasoning.summary.isEmpty == false { @@ -733,34 +612,21 @@ final class ReviewMonitorPreviewAppServerRuntime { } private func emitCancelledState( - _ snapshot: CodexThreadSnapshot, - for fixture: ReviewMonitorPreviewChatLogFixture + _ storedThread: CodexAppServerTestStoredThread, + for fixture: ReviewMonitorPreviewChatLogFixture, + using runtime: CodexAppServerTestRuntime ) async { do { - try await ensureStarted() - guard let runtime else { - return - } - await runtime.transport.waitForNotificationStreamCount(1) try await runtime.notificationEmitter.emitThreadStatusChanged( threadID: fixture.chatID, status: .idle ) - let turn = snapshot.turns?.last ?? CodexTurnSnapshot( - id: fixture.previewFallbackTurnID, - state: .interrupted - ) - let items = try turn.items.map { - try makePreviewTestItem( - id: $0.id, - kind: $0.kind, - content: $0.content, - cwd: fixture.cwd - ) + 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: CodexAppServerTestTurn(snapshot: turn, items: items) + turn: turn ) turnCompletionNotificationCount += 1 } catch { @@ -769,12 +635,92 @@ final class ReviewMonitorPreviewAppServerRuntime { } } -private struct PreviewTurnInterruptParams: Decodable, Sendable { - var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" +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." + ) + } + 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." + ) } + guard let initialTurns = initialThreadSnapshot.turns, + initialTurns.isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "Preview stored threads require an explicit turn." + ) + } + guard modelProvider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + throw CodexAppServerTestError.invalidFixture( + "Preview stored threads require an explicit model provider." + ) + } + 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) + } + 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 makePreviewTestItem( @@ -783,6 +729,16 @@ private func makePreviewTestItem( 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: + break + } switch content { case .message(let message): return try .agentMessage(id: id, text: message.text, phase: message.phase) @@ -918,14 +874,6 @@ private extension CodexThreadItem { } } -private struct PreviewThreadArchiveParams: Decodable, Sendable { - var threadID: String - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - } -} - private extension CodexTurnSnapshot.State { var isTerminalForPreview: Bool { switch self { @@ -937,40 +885,20 @@ private extension CodexTurnSnapshot.State { } } -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, state: .inProgress)] - return turnID +private extension Date { + var previewWholeSecondDate: Date { + Date(timeIntervalSince1970: timeIntervalSince1970.rounded(.towardZero)) } } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift index 1d70050..88ace51 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -8,42 +8,46 @@ 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 + 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) { - runtime.startStreaming(interval: interval) + requireLifetime().startStreaming(interval: interval) } package func cancelStreamingForTesting() { - runtime.cancelStreaming() + requireLifetime().cancelStreaming() } package func stopStreamingForTesting() async { - await runtime.stopStreaming() + await requireLifetime().stopStreaming() } @discardableResult @@ -51,32 +55,63 @@ public final class ReviewMonitorPreviewContentSource { 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) + await requireLifetime().snapshotForTesting(chatID: chatID) } public func observedTurnStateForTesting( chatID: CodexThreadID ) -> CodexTurnSnapshot.State? { - runtime.observedTurnStateForTesting(chatID: chatID) + requireLifetime().observedTurnStateForTesting(chatID: chatID) } public func interruptRequestCountForTesting() async -> Int { - await runtime.interruptRequestCountForTesting() + await requireLifetime().interruptRequestCountForTesting() } public func turnCompletionNotificationCountForTesting() -> Int { - runtime.turnCompletionNotificationCountForTesting() + requireLifetime().turnCompletionNotificationCountForTesting() } public func archiveRequestCountForTesting() async -> Int { - await runtime.archiveRequestCountForTesting() + await requireLifetime().archiveRequestCountForTesting() + } + + package func startAndWaitForTesting() async { + await requireLifetime().startAndWaitForTesting() + } + + 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 } } @@ -151,7 +186,7 @@ public enum ReviewMonitorPreviewContent { } } - package enum PreviewStreamMode { + package enum PreviewStreamMode: Sendable { case update case complete case textDelta @@ -181,7 +216,7 @@ public enum ReviewMonitorPreviewContent { } } - package struct PreviewChatLogStreamStep { + package struct PreviewChatLogStreamStep: Sendable { let itemName: String let kind: CodexThreadItem.Kind let content: CodexThreadItem.Content @@ -197,14 +232,21 @@ public enum ReviewMonitorPreviewContent { } } - package struct PreviewStreamFrame { + package struct PreviewStreamFrame: Sendable { let step: PreviewChatLogStreamStep let cycle: Int } public 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( @@ -218,13 +260,12 @@ 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 ) } @@ -233,10 +274,6 @@ public enum ReviewMonitorPreviewContent { } public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { - let store = CodexReviewStore.makePreviewStore( - seed: .init(initialSettingsSnapshot: makePreviewSettingsSnapshot()) - ) - let accounts = makePreviewAccounts() let cwd = "/path/to/workspace-alpha" let now = Date() let chatItems = makeCommandOutputPreviewChatLogItems(cwd: cwd) @@ -256,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 ) } @@ -724,7 +757,8 @@ public enum ReviewMonitorPreviewContent { ) chatLogFixtures.append( makeChatLogFixture( - for: chatFixture + for: chatFixture, + referenceDate: now )) } } @@ -732,7 +766,8 @@ public enum ReviewMonitorPreviewContent { } private static func makeChatLogFixture( - for chatFixture: PreviewChatFixture + for chatFixture: PreviewChatFixture, + referenceDate: Date ) -> ReviewMonitorPreviewChatLogFixture { let turn = CodexTurnSnapshot( id: chatFixture.turnID, @@ -752,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, diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index 4e46ede..edf068a 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -2,257 +2,335 @@ 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( + 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( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .completed(finalReview: "Looks good.")) + 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 ) #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 backendRejectsItemDeltaWithoutRequiredTurnID() 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: "item/agentMessage/delta", - params: TestDeltaWithoutTurnIDNotification( - threadID: "review-thread", - itemID: "msg-1", - delta: "Looks good.", - phase: "final_answer" - ) - ) - #expect( - try await iterator.next() == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - do { - _ = try await iterator.next() - Issue.record("Expected the malformed current-v2 notification to terminate the event stream.") - } catch { - #expect( - error.localizedDescription.contains( - "Current-v2 notification is missing required field turnId." - ) + + 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 backendDoesNotPromoteThreadlessAgentMessageToInlineReview() 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: "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 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: "thread-1", - 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: "thread-1", model: "gpt-5")) - #expect( - try await iterator.next() - == .failed(.missingReviewOutput(turnID: "turn-1"))) + try await attempt.observeTerminal() + == .failed(.missingReviewOutput(turnID: makeTurnID("turn-1")))) } @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: "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") - ) + 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(.missingReviewOutput(turnID: "turn-1"))) + 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: "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()) - var iterator = eventSequence(attempt).makeAsyncIterator() - - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init( - id: "turn-1", - status: "failed", - error: .init( - message: "Capacity exhausted.", - codexErrorInfo: "serverOverloaded", - additionalDetails: "retry after the maintenance window" - ) - ) - ) + + 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: "review-thread", model: "gpt-5")) - #expect( - try await iterator.next() + try await attempt.observeTerminal() == .failed( .turnFailed( .init( @@ -268,62 +346,49 @@ struct AppServerClientTests { @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()) - var iterator = eventSequence(attempt).makeAsyncIterator() - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init(id: "turn-1", status: "interrupted") - ) + try await emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .interrupted ) - #expect( - try await iterator.next() - == .started(turnID: "turn-1", reviewThreadID: "review-thread", model: "gpt-5")) - #expect(try await iterator.next() == .interrupted(message: nil)) + #expect(try await attempt.observeTerminal() == .interrupted(message: nil)) } - @Test func backendPreservesUnknownTerminalStatusError() 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") - await runtime.transport.waitForNotificationStreamCount(1) - let backend = AppServerCodexReviewBackend(appServer: runtime.server) - - let attempt = try await backend.startReview(makeReviewStart()) - var iterator = eventSequence(attempt).makeAsyncIterator() - - try await runtime.transport.emitServerNotification( - method: "turn/completed", - params: TestTurnNotification( - threadID: "review-thread", - turn: .init( - id: "turn-1", - status: "pausedByFutureServer", - error: .init( - message: "Future terminal detail.", - codexErrorInfo: "futureErrorCode", - additionalDetails: "future additional detail" - ) - ) - ) + @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() + AppServerReviewTerminalMapper.observed( + outcome, + expectedAttempt: attempt + ) == .failed( .invalidTerminalStatus( rawStatus: "pausedByFutureServer", - turnID: "turn-1", + turnID: expectedTurnID, turnFailure: .init( message: "Future terminal detail.", code: .unknown(rawValue: "futureErrorCode"), @@ -334,366 +399,542 @@ struct AppServerClientTests { ) } + @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/started", - params: TestItemNotification( - threadID: "review-thread-1", - turnID: "turn-1", - item: .init(type: "agentMessage", id: "msg-1", text: "") - ) + 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-1", - turnID: "turn-1", - itemID: "msg-1", - delta: "first" - ) + try await runtime.notificationEmitter.emitAgentMessageDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: "msg-1", + delta: "first" ) - try await runtime.transport.emitServerNotification( - method: "item/started", - params: TestItemNotification( - threadID: "review-thread-2", - turnID: "turn-2", - item: .init(type: "agentMessage", id: "msg-1", text: "") - ) + try await runtime.notificationEmitter.emitItemStarted( + threadID: "thread-2", + turnID: "turn-2", + 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-2", + turnID: "turn-2", + itemID: "msg-1", + delta: "second" ) - 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 emitTurn( + on: runtime, + threadID: "thread-1", + turnID: "turn-1", + state: .completed, + items: [.exitedReviewMode(id: "review-output-1", review: "first")] ) - 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 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/started", - params: TestItemNotification( - threadID: "review-thread", - turnID: "turn-1", - item: .init( - type: "commandExecution", - id: "cmd-1", - command: "swift test", - aggregatedOutput: "", - status: "inProgress" - ) + 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: "first" - ) + try await runtime.notificationEmitter.emitCommandExecutionOutputDelta( + threadID: "thread-1", + turnID: "turn-1", + itemID: "cmd-1", + delta: "first" ) - 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: "second" ) - 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 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) - // The review worker can reach its own finalizer after shutdown cleanup. - // Repeated finalization must not reacquire destructive cleanup authority. - await backend.cleanupReview(attempt.run) - await backend.cleanupActiveReviewsForShutdown( - .init(reason: .init(message: "Review runtime stopped."), recoveryWaitingRuns: []) + let cleanup = await backend.cleanupRetainedReviews( + [attempt.attempt], + additionalThreadIDs: [] ) - 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 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.enqueueFailure( + .response(code: -32_000, message: "start unavailable"), + for: .threadStart + ) + 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.enqueueJSON( - #"{"thread":{"id":"review-thread"},"model":"gpt-5"}"#, - for: "thread/resume" + 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.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 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") + ) + try await runtime.transport.enqueueFailure( + .response( + code: -32602, + message: "expected active turn id turn-old but found turn-new" + ), + for: .turnInterrupt ) - await runtime.transport.enqueueFailure( - code: -32602, - message: "expected active turn id turn-old but found turn-new", - for: "turn/interrupt" + try await runtime.transport.handleTurnInterrupt { _ in } + try await runtime.transport.enqueueThreadResume( + makeStoredThread(id: "thread-1") ) - 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.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: "review-thread") + 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 { @@ -701,45 +942,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 { @@ -748,28 +986,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 @@ -788,177 +1124,142 @@ 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 extension CodexAppServerTestTransport { + func enqueueThreadStart( + threadID: String, + model: String? = nil + ) throws { + try enqueueThreadStart( + makeStoredThread( + id: .init(rawValue: threadID), + model: model ?? "gpt-5" + ) + ) } -} -private struct TestTurn: Encodable, Sendable { - var id: String - var status: String - var items: [TestItem] - var error: TestTurnError? - - init( - id: String, - status: String, - items: [TestItem] = [], - error: TestTurnError? = nil - ) { - self.id = id - self.status = status - self.items = items - self.error = error - } -} - -private struct TestTurnError: Encodable, Sendable { - var message: String - var codexErrorInfo: String? - var additionalDetails: String? -} - -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 + func enqueueReviewStart( + turnID: String, + reviewThreadID: String + ) throws { + try enqueueReviewStart( + makeTestTurn(id: .init(rawValue: turnID), state: .inProgress), + reviewThreadID: .init(rawValue: reviewThreadID) + ) } } -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 - } +private func makeTestTurn( + id: CodexTurnID, + state: CodexTurnSnapshot.State = .completed, + items: [CodexAppServerTestItem] = [] +) throws -> CodexAppServerTestTurn { + try .init( + snapshot: .init( + id: id, + state: state, + items: items.map(\.domainProjection) + ), + items: items + ) } -private struct TestAgentMessageNotification: Encodable, Sendable { - var message: String - var phase: String? = nil +private func emitTurn( + on runtime: CodexAppServerTestRuntime, + threadID: CodexThreadID, + turnID: CodexTurnID, + state: CodexTurnSnapshot.State, + items: [CodexAppServerTestItem] = [] +) async throws { + try await runtime.notificationEmitter.emitTurnCompleted( + threadID: threadID, + turn: makeTestTurn(id: turnID, state: state, items: items) + ) } -private struct TestItemNotification: Encodable, Sendable { - var threadID: String - var turnID: String - var item: TestItem - var startedAtMs: Int64 = 0 - var completedAtMs: Int64 = 0 - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case item - case startedAtMs - case completedAtMs - } +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, + 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 + ) } -private struct TestItem: Encodable, Sendable { - var type: String - var id: String - var review: String? - var text: String? - var phase: String? - var command: String? - var commandActions: [String] - var cwd: String? - var aggregatedOutput: String? - var exitCode: Int? - var status: String? - - init( - type: String, - id: String, - review: String? = nil, - text: String? = nil, - phase: String? = nil, - command: String? = nil, - cwd: String? = nil, - aggregatedOutput: String? = nil, - exitCode: Int? = nil, - status: String? = nil - ) { - self.type = type - self.id = id - self.review = review - self.text = text - self.phase = phase - self.command = command - self.commandActions = [] - self.cwd = type == "commandExecution" ? (cwd ?? "/tmp/project") : cwd - self.aggregatedOutput = aggregatedOutput - self.exitCode = exitCode - self.status = status - } - - 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 2b8196c..f6dbf41 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -19,7 +19,6 @@ private extension CodexReviewStore { externalURLOpener: @escaping ExternalURLOpener = { _ in }, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, - shutdownCleanupTimeout: Duration = .seconds(2), networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), networkRecoveryPolicy: CodexReviewNetworkRecoveryPolicy = .default, appServerLifecycleHandler: CodexReviewAppServerLifecycleHandler? = nil, @@ -31,14 +30,15 @@ private extension CodexReviewStore { externalURLOpener: externalURLOpener, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, appServerFactory: { codexHomeURL in try await CodexAppServerTestRuntime.start( transport: transport, - codexHome: codexHomeURL.path + configuration: .init(localProcess: .init( + codexHomeURL: codexHomeURL + )) ).server } ) @@ -56,7 +56,6 @@ 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, @@ -69,7 +68,6 @@ private extension CodexReviewStore { mcpHTTPServerFactory: mcpHTTPServerFactory, mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, - shutdownCleanupTimeout: shutdownCleanupTimeout, networkMonitor: networkMonitor, networkRecoveryPolicy: networkRecoveryPolicy, appServerLifecycleHandler: appServerLifecycleHandler, @@ -77,7 +75,9 @@ private extension CodexReviewStore { let transport = try await transportFactory(codexHomeURL) return try await CodexAppServerTestRuntime.start( transport: transport, - codexHome: codexHomeURL.path + configuration: .init(localProcess: .init( + codexHomeURL: codexHomeURL + )) ).server } ) @@ -185,13 +185,9 @@ 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), @@ -210,13 +206,9 @@ 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], @@ -243,13 +235,9 @@ struct CodexReviewHostTests { @Test func liveStorePassesRuntimePreferenceMCPPortAndPathToHTTPServerFactory() 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 capturedConfiguration: CodexReviewMCPHTTPServer.Configuration? var capturedLogProjectionProvider: ReviewMCPLogProjectionProvider? let store = CodexReviewStore.makeLiveStoreForTesting( @@ -541,16 +529,12 @@ struct CodexReviewHostTests { @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.enqueueAccount( + try CodexAppServerTestAccount(kind: .apiKey), + requiresOpenAIAuth: false ) - 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.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": try temporaryHome().path], transport: transport @@ -561,11 +545,11 @@ struct CodexReviewHostTests { await Task.yield() #expect(store.auth.selectedAccount?.kind == .apiKey) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, ]) } @@ -573,33 +557,22 @@ struct CodexReviewHostTests { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", 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.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-1", - authURL: "https://example.com/auth" - ), - 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) + 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.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus )), - for: "account/rateLimits/read" + 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], @@ -620,7 +593,7 @@ struct CodexReviewHostTests { #expect(failure == .alreadyInProgress) } #expect(store.auth.isAuthenticating) - #expect(await transport.recordedRequests(method: "account/login/start").count == 1) + #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.") @@ -632,50 +605,37 @@ struct CodexReviewHostTests { try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( to: mainCodexHomeURL.appendingPathComponent("auth.json") ) - try await transport.emitServerNotificationJSON( - method: "account/login/completed", - json: #"{"loginId":"login-1","success":true,"error":null}"# - ) - try await transport.emitServerNotificationJSON( - method: "account/updated", - json: #"{"authMode":"chatgpt","planType":"plus"}"# + 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(7) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/read", - "account/rateLimits/read", + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountRead, + .accountRateLimitsRead, ]) await store.stop() } @Test func liveStoreCancelsLoginWhenOpeningAuthenticationURLFails() 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" - ), - for: "account/login/start" - ) - try await transport.enqueue( - AppServerAPI.Account.Login.Cancel.Response(), - for: "account/login/cancel" - ) + 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.") ) @@ -694,17 +654,152 @@ struct CodexReviewHostTests { == "Failed to open the authentication URL: https://example.com/auth" ) #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/login/cancel", + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountLoginCancel, ]) 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() + await waitUntil { 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 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 + + await waitUntil { + 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) @@ -713,65 +808,50 @@ struct CodexReviewHostTests { to: mainCodexHomeURL.appendingPathComponent("auth.json") ) 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) + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - 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") + 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.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await authTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-2", - authURL: "https://example.com/auth" - ), - for: "account/login/start" - ) - try await authTransport.enqueue( - AppServerAPI.Account.Read.Response( - account: .init(email: "new@example.com", planType: "plus") - ), - for: "account/read" - ) - try await authTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 25, windowDurationMins: 300) + try await authTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) + try await authTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) + try await authTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 25 + )) 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 refreshTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 44, windowDurationMins: 300) + await refreshTransport.holdNext(.accountRateLimitsRead, gate: refreshGate) + try await refreshTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) + try await refreshTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 44 + )) var nonPrimaryTransports = [authTransport, refreshTransport] var nonPrimaryRuntimeIndex = 0 var refreshCodexHomeURL: URL? @@ -803,19 +883,18 @@ struct CodexReviewHostTests { await authTransport.waitForNotificationStreamCount(1) await authTransport.waitForRequestCount(2) #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - 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.type == "chatgpt") - try await authTransport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-2", success: true) + let loginRequest = try #require( + await authTransport.recordedRequests(for: .accountLoginStart).first ) - try await authTransport.emitServerNotificationJSON( - method: "account/updated", - json: #"{"authMode":"chatgpt","planType":"plus"}"# + #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 @@ -826,12 +905,12 @@ struct CodexReviewHostTests { #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/read", - "account/rateLimits/read", + #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) == [ @@ -853,10 +932,10 @@ struct CodexReviewHostTests { #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(await refreshTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .accountRateLimitsRead, ]) } @@ -871,37 +950,34 @@ struct CodexReviewHostTests { 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" - ) - try await mainTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) - try await mainTransport.enqueue( - AppServerAPI.Config.Read.Response(config: .init(model: "gpt-5")), - for: "config/read" + requiresOpenAIAuth: false ) - try await mainTransport.enqueue(AppServerAPI.Model.List.Response(data: []), for: "model/list") + 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.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" - ) - try await refreshTransport.enqueue( - AppServerAPI.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 44, windowDurationMins: 300) + try await refreshTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" + requiresOpenAIAuth: false ) + try await refreshTransport.enqueueRateLimits(try makeHostRateLimits( + planType: nil, + windowDurationMinutes: 300, + usedPercent: 44 + )) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -920,9 +996,9 @@ struct CodexReviewHostTests { #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(await refreshTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, ]) } @@ -935,31 +1011,22 @@ struct CodexReviewHostTests { accounts: ["existing@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" - ), - 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) + 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) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "new@example.com", + planType: .plus )), - for: "account/rateLimits/read" + 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], @@ -984,14 +1051,14 @@ struct CodexReviewHostTests { try Data(#"{"tokens":{"id_token":"new@example.com"}}"#.utf8).write( to: mainCodexHomeURL.appendingPathComponent("auth.json") ) - try await transport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification(loginID: "login-new", success: true) - ) - try await transport.emitServerNotificationJSON( - method: "account/updated", - json: #"{"authMode":"chatgpt","planType":"plus"}"# + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "login-new", + completion: .succeeded ) + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus + )) await transport.waitForRequestCount(7) await waitUntil { store.auth.selectedAccount?.accountKey == "new@example.com" @@ -1004,14 +1071,14 @@ struct CodexReviewHostTests { "new@example.com", "existing@example.com", ]) - #expect(await transport.recordedRequests().map(\.method) == [ - "initialize", - "account/read", - "config/read", - "model/list", - "account/login/start", - "account/read", - "account/rateLimits/read", + #expect(await transport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountRead, + .configurationRead, + .modelList, + .accountLoginStart, + .accountRead, + .accountRateLimitsRead, ]) } @@ -1024,36 +1091,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.Account.RateLimits.Response(rateLimits: .init( - limitID: "codex", - primary: .init(usedPercent: 10, windowDurationMins: 300) + try await mainTransport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - 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") + 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.enqueue(AppServerAPI.Initialize.Response(), for: "initialize") - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Response.chatgpt( - loginID: "login-2", - authURL: "https://example.com/auth" - ), - for: "account/login/start" - ) - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Cancel.Response(), - for: "account/login/cancel" - ) + 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.") @@ -1089,10 +1143,10 @@ struct CodexReviewHostTests { #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) await store.stop() #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path) == false) - #expect(await loginTransport.recordedRequests().map(\.method) == [ - "initialize", - "account/login/start", - "account/login/cancel", + #expect(await loginTransport.recordedRequests().map(\.request.operation) == [ + .initialize, + .accountLoginStart, + .accountLoginCancel, ]) } @@ -1104,24 +1158,20 @@ struct CodexReviewHostTests { 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: "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( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) + requiresOpenAIAuth: false + ) + try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) + try await transport.enqueueModels(.init(models: [])) + try await transport.enqueueRateLimits(try makeHostRateLimits( + planType: .pro, + windowDurationMinutes: 300, + usedPercent: 10 + )) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], transport: transport @@ -1132,21 +1182,34 @@ struct CodexReviewHostTests { #expect(await waitUntil(timeout: .seconds(1)) { store.auth.selectedAccount?.rateLimits.first?.usedPercent == 10 }) - try await transport.emitServerNotification( - method: "account/rateLimits/updated", - params: TestRateLimitsUpdatedNotification(rateLimits: .init( + try await transport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( limitID: "openai", - primary: .init(usedPercent: 99, windowDurationMins: 300), - planType: "pro" - )) - ) - try await transport.emitServerNotification( - method: "account/rateLimits/updated", - params: TestRateLimitsUpdatedNotification(rateLimits: .init( + 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", - primary: .init(usedPercent: 11, windowDurationMins: 300) - )) - ) + 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 }) @@ -1165,45 +1228,39 @@ 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( @@ -1224,18 +1281,18 @@ struct CodexReviewHostTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-first" } + await waitUntil { 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) - #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 { @@ -1248,37 +1305,30 @@ 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( @@ -1294,21 +1344,21 @@ struct CodexReviewHostTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.run.turnID == "turn-active" } + await waitUntil { 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 liveStoreSwitchAccountFailsWhenSavedAuthIsMissing() async throws { @@ -1324,23 +1374,20 @@ struct CodexReviewHostTests { 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) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "first@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) + 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 + )) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], transport: transport @@ -1361,18 +1408,14 @@ 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], mcpHTTPServerFactory: { store, _, _ in @@ -1389,102 +1432,99 @@ 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], - 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(elapsed < 1) - #expect(result.core.lifecycle.status == .cancelled) - #expect(await transport.recordedRequests().map(\.method).contains("turn/interrupt")) + #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 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) + try await transport.enqueueSuccess(for: .threadDelete) let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - shutdownCleanupTimeout: .seconds(1), networkMonitor: networkMonitor, networkRecoveryPolicy: .init(sleep: { _ in }), transport: transport @@ -1498,11 +1538,15 @@ struct CodexReviewHostTests { request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) } - try #require(await waitUntil(timeout: .seconds(2)) { store.reviewRuns.first?.core.run.turnID == "turn-1" }) + 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(\.method).contains("turn/interrupt") + await transport.recordedRequests().map(\.request.operation).contains(.turnInterrupt) }) let stopFinished = CompletionFlag() @@ -1512,24 +1556,20 @@ struct CodexReviewHostTests { } await stopTask.value let result = try await reviewRead.value - let methods = await transport.recordedRequests().map(\.method) + let methods = await transport.recordedRequests().map(\.request.operation) #expect(await stopFinished.isCompleted()) - #expect(result.core.lifecycle.status == .cancelled) - #expect(methods.contains("turn/interrupt")) - #expect(methods.filter { $0 == "thread/delete" }.count == 2) + #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.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], transport: transport @@ -1537,7 +1577,7 @@ struct CodexReviewHostTests { await store.start(forceRestartIfNeeded: true) await transport.waitForNotificationStreamCount(1) - await transport.finishNotificationStreams(throwing: TestTransportClosedError()) + await transport.failConnection(.closed) await waitUntil { if case .failed = store.serverState { return true @@ -1549,10 +1589,56 @@ struct CodexReviewHostTests { Issue.record("Expected failed server state.") return } - #expect(message.contains("JSON-RPC transport is closed")) + #expect(message.contains("The Codex app-server transport is closed.")) #expect(store.serverURL == nil) } + @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(result.core.isTerminal) + #expect(store.serverURL == nil) + #expect(methods.contains(.threadResume) == false) + #expect(methods.contains(.turnInterrupt) == false) + #expect(methods.contains(.threadDelete) == false) + } + @Test func liveStoreCleansIsolatedLoginRuntimeWhenMainNotificationStreamCloses() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) @@ -1562,29 +1648,18 @@ 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" - ), - for: "account/login/start" - ) - try await loginTransport.enqueue( - AppServerAPI.Account.Login.Cancel.Response(), - for: "account/login/cancel" - ) + try await loginTransport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) + try await loginTransport.enqueueChatGPTLoginCancellation(.canceled) let externalURLOpener = FakeExternalURLOpener() var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( @@ -1608,7 +1683,7 @@ struct CodexReviewHostTests { #expect(FileManager.default.fileExists(atPath: resolvedIsolatedCodexHomeURL.path)) #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - await mainTransport.finishNotificationStreams(throwing: TestTransportClosedError()) + await mainTransport.failConnection(.closed) await waitUntil { if case .failed = store.serverState { return true @@ -1619,6 +1694,8 @@ struct CodexReviewHostTests { 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 { @@ -1633,34 +1710,27 @@ 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") - try await firstTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/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 + )) + 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( @@ -1678,8 +1748,8 @@ 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 liveStoreFsyncsRemovalJournalBeforeUpstreamLogout() async throws { @@ -1694,34 +1764,27 @@ struct CodexReviewHostTests { .write(to: mainCodexHomeURL.appendingPathComponent("auth.json")) let logoutGate = AsyncGate() 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") - try await firstTransport.enqueue(AppServerAPI.Account.Read.Response(), for: "account/read") - await firstTransport.holdNext(method: "account/logout", gate: logoutGate) + 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) + await firstTransport.holdNext(.accountLogout, gate: logoutGate) 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 transports = [firstTransport, secondTransport] let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1737,13 +1800,20 @@ struct CodexReviewHostTests { let journal = try #require(JSONSerialization.jsonObject(with: journalData) as? [String: Any]) #expect(journal["phase"] as? String == "prepared") #expect(journal["mayApplyIrreversibleLogout"] as? Bool == true) - try await firstTransport.emitServerNotification( - method: "account/rateLimits/updated", - params: TestRateLimitsUpdatedNotification(rateLimits: .init( + try await firstTransport.notificationEmitter.emitRateLimitsUpdated(.init(snapshot: try .init( limitID: "codex", - primary: .init(usedPercent: 12, windowDurationMins: 300) - )) - ) + limitName: nil, + primary: .init( + usedPercent: 12, + 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 == 12 }) @@ -1806,29 +1876,23 @@ struct CodexReviewHostTests { contentsOf: 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: "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) + try await transport.enqueueAccount( + try CodexAppServerTestAccount(kind: .chatGPT( + email: "active@example.com", + planType: .pro )), - for: "account/rateLimits/read" - ) - await transport.enqueueFailure( - code: -32603, - message: "logout unavailable", - for: "account/logout" + 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 store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], @@ -1895,22 +1959,19 @@ 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( @@ -1955,25 +2016,17 @@ 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" - ), - for: "account/login/start" - ) + try await loginTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) let externalURLOpener = FakeExternalURLOpener() var isolatedCodexHomeURL: URL? let store = CodexReviewStore.makeLiveStoreForTesting( @@ -1993,13 +2046,9 @@ struct CodexReviewHostTests { try await store.addAccount() await loginTransport.waitForNotificationStreamCount(1) #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - try await loginTransport.emitServerNotification( - method: "account/login/completed", - params: TestLoginCompletedNotification( - loginID: "login-2", - success: false, - error: "login completion failed" - ) + try await loginTransport.notificationEmitter.emitLoginCompleted( + loginID: "login-2", + completion: .failed(message: "login completion failed") ) let resolvedIsolatedCodexHomeURL = try #require(isolatedCodexHomeURL) @@ -2008,9 +2057,9 @@ struct CodexReviewHostTests { 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, ]) } @@ -2065,358 +2114,110 @@ 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 +private extension CodexAppServerTestTransport { + nonisolated var notificationEmitter: CodexAppServerTestNotificationEmitter { + CodexAppServerTestNotificationEmitter(transport: self) } - enum CodingKeys: String, CodingKey { - case loginID = "loginId" - case success - case error + func enqueueThreadStart(threadID: CodexThreadID, model: String) throws { + try enqueueThreadStart(makeHostStoredThread(id: threadID, model: model)) } -} - -private struct TestAccountReadResponse: Encodable, Sendable { - var account: TestAccount - var requiresOpenAIAuth = false - enum CodingKeys: String, CodingKey { - case account - case requiresOpenAIAuth = "requiresOpenaiAuth" + func enqueueReviewStart( + turnID: CodexTurnID, + reviewThreadID: CodexThreadID + ) throws { + let turn = try CodexAppServerTestTurn( + snapshot: .init(id: turnID, state: .inProgress), + items: [] + ) + try enqueueReviewStart(turn, reviewThreadID: reviewThreadID) } } -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 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)]) + )] + ) } -private struct EmptyResponse: Codable, Equatable, Sendable { - init() {} +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 + ) } -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 - } - } - } - } - - 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) - } - } - } - } - - 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 - var status: 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, status: CodexTurnStatus.inProgress.rawValue), - forKey: .turn - ) - try container.encodeIfPresent(reviewThreadID, forKey: .reviewThreadID) - } - } - } - } - - 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 - } - } - } - - enum Login { - struct Params: Codable, Equatable, Sendable { - var type: String - var apiKey: String? - var codexStreamlinedLogin: Bool - - init( - type: String = "chatgpt", - apiKey: String? = nil, - codexStreamlinedLogin: Bool = true - ) { - self.type = type - self.apiKey = apiKey - self.codexStreamlinedLogin = codexStreamlinedLogin - } - } - - enum Response: Codable, Equatable, Sendable { - case apiKey - case chatgpt(loginID: String, authURL: String) - case chatgptDeviceCode(loginID: String, verificationURL: String, userCode: String) - case chatgptAuthTokens - - private enum CodingKeys: String, CodingKey { - case type - case loginID = "loginId" - case authURL = "authUrl" - case verificationURL = "verificationUrl" - case userCode - } - - 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) - ) - 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)" - ) - } - } - - 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): - try container.encode("chatgpt", forKey: .type) - try container.encode(loginID, forKey: .loginID) - try container.encode(authURL, forKey: .authURL) - 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) - } - } - } - - enum Cancel { - struct Response: Codable, Equatable, Sendable { - init() {} - } - } - - } - } +private func makeHostStoredThread( + id: CodexThreadID, + model: String = "gpt-5" +) 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: [], + 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 + ) } @MainActor diff --git a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift b/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift deleted file mode 100644 index 9f0eb6b..0000000 --- a/Tests/CodexReviewKitTests/Application/ReviewBackendEventSessionTests.swift +++ /dev/null @@ -1,128 +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) - } - - @Test func callerCancellationDoesNotBecomeServerInterruption() async throws { - let interruptedSession = ReviewBackendEventSession(run: makeRun()) - let interruptedAttempt = await interruptedSession.attempt() - await interruptedSession.receive([.interrupted(message: nil)]) - - #expect(try await nextEvent(from: interruptedAttempt.events) == .interrupted(message: nil)) - - let cancelledSession = ReviewBackendEventSession(run: makeRun()) - let cancelledAttempt = await cancelledSession.attempt() - await cancelledSession.finish(throwing: CancellationError()) - - await #expect(throws: CancellationError.self) { - try await cancelledAttempt.events.next() - } - } -} - -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 f71fb50..47881c2 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 0000000..4a4adfc --- /dev/null +++ b/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift @@ -0,0 +1,76 @@ +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 backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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.")) + 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 3cc659f..380dad7 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -6,6 +6,23 @@ import CodexReviewTesting @Suite("Codex review store", .serialized) @MainActor struct CodexReviewStoreCommandTests { + @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 backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( @@ -21,19 +38,29 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found.")) 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"]) } } @@ -51,17 +78,17 @@ 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.")) 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) } } @@ -81,13 +108,13 @@ 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.")) let final = try await awaited - #expect(final.core.lifecycle.status == .succeeded) + #expect(final.presentation.status == .succeeded) } } @@ -107,17 +134,17 @@ 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") } } @@ -137,11 +164,11 @@ 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) } } @@ -161,7 +188,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 +197,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.") } } @@ -233,7 +260,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result - #expect(read.core.lifecycleMessage == "Review completed.") + #expect(read.presentation.lifecycle.message == "Review completed.") } } @@ -307,13 +334,68 @@ 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.")) _ = try await result } } + @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 backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( @@ -324,14 +406,20 @@ struct CodexReviewStoreCommandTests { 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", @@ -357,19 +445,24 @@ 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")) _ = 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") ))) } @@ -391,27 +484,28 @@ 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") } } @@ -426,7 +520,7 @@ struct CodexReviewStoreCommandTests { networkRecoveryPolicy: .init( outageDebounce: .seconds(10), recoverySettle: .seconds(1), - sleep: { _ in await debounceGate.wait() } + sleep: { _ in try? await debounceGate.wait() } ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -462,7 +556,7 @@ struct CodexReviewStoreCommandTests { await backend.yield(.completed(finalReview: "No issues found.")) let read = try await result - #expect(read.core.lifecycle.status == .succeeded) + #expect(read.presentation.status == .succeeded) } } @@ -484,31 +578,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")) + 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")) _ = 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let settleGate = AsyncGate() let sleeper = ControlledTestSleeper(gate: settleGate) @@ -534,7 +632,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,27 +649,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 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -578,64 +684,56 @@ 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -659,27 +757,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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -706,27 +805,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 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -750,27 +850,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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) + await backend.setDiscardedRestartAttempts([initialRun, recoveredRun]) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -791,13 +893,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,27 +918,29 @@ 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(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), @@ -855,18 +962,17 @@ 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 @@ -874,10 +980,11 @@ struct CodexReviewStoreCommandTests { #expect(cleanup.didComplete) #expect(requests.count == 2) - #expect(requests.allSatisfy { $0.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.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( @@ -890,13 +997,14 @@ 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(nextAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -916,68 +1024,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(nextAttempt: run) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -997,69 +1069,30 @@ 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(store.reviewRun(id: makeRunID("run-1")) == nil) + let runtimeState = store.runtimeReviewRunState(runID: makeRunID("run-1")) + #expect(runtimeState.activeAttempt == 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(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(nextAttempt: run) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1080,101 +1113,35 @@ 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 requests = await recorder.recordedRequests() - let read = try store.readReview(runID: "run-1") + let read = try store.readReview(runID: makeRunID("run-1")) #expect(result.didComplete) #expect(requests.count == 2) #expect(requests.allSatisfy { $0.reason.message == "Review runtime stopped." }) - #expect(requests.allSatisfy { $0.recoveryWaitingRuns == [run] }) - #expect(read.core.lifecycle.status == .cancelled) - let runtimeState = store.runtimeReviewRunState(runID: "run-1") + #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(nextAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1190,12 +1157,15 @@ 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") } } @@ -1207,7 +1177,7 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in await debounceGate.wait() }) + networkRecoveryPolicy: .init(sleep: { _ in try? await debounceGate.wait() }) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1217,12 +1187,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")) 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 @@ -1254,15 +1227,19 @@ 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.")) _ = try await result @@ -1291,14 +1268,14 @@ 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()) 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") } } @@ -1317,7 +1294,7 @@ struct CodexReviewStoreCommandTests { await backend.finishEvents(throwing: StreamClosedError()) let read = try await result - #expect(read.core.lifecycle.status == .failed) + #expect(read.presentation.status == .failed) } } @@ -1340,9 +1317,9 @@ struct CodexReviewStoreCommandTests { await backend.yield(.interrupted(message: nil)) let read = try await result - #expect(read.core.lifecycle.status == .failed) - #expect(read.core.lifecycle.errorMessage == "Review was interrupted by the backend.") - #expect(read.core.lifecycle.failure == .interruptedByBackend(message: nil)) + #expect(read.presentation.status == .failed) + #expect(read.core.failure?.message == "Review was interrupted by the backend.") + #expect(read.core.failure == .interruptedByBackend(message: nil)) } } @@ -1354,7 +1331,7 @@ struct CodexReviewStoreCommandTests { ) let failure = ReviewBackendFailure.invalidTerminalStatus( rawStatus: "future-terminal", - turnID: "turn-1", + turnID: makeTurnID("turn-1"), turnFailure: .init( message: "Future terminal failure", code: .unknown(rawValue: "future_code"), @@ -1375,31 +1352,32 @@ struct CodexReviewStoreCommandTests { await backend.yield(.failed(failure)) let read = try await result - #expect(read.core.lifecycle.status == .failed) - #expect(read.core.lifecycle.failure == failure) + #expect(read.presentation.status == .failed) + #expect(read.core.failure == failure) #expect( - read.core.lifecycle.errorMessage + read.core.failure?.message == "Review ended with invalid terminal status future-terminal." ) } } @Test func pendingNetworkOutageDefersStreamFailureUntilRecovery() 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(nextAttempt: initialRun) + await backend.setNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let outageSleepStarted = AsyncGate() let debounceGate = AsyncGate() @@ -1412,7 +1390,7 @@ struct CodexReviewStoreCommandTests { recoverySettle: .seconds(1), sleep: { _ in await outageSleepStarted.open() - await debounceGate.wait() + try? await debounceGate.wait() } ) ) @@ -1424,8 +1402,11 @@ struct CodexReviewStoreCommandTests { 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) + try? await outageSleepStarted.wait() + await backend.yield( + .failed(.connectionTerminated(.closed)), + for: initialRun + ) let failedBeforeOutageConfirmed = await StoreSnapshotProbe(store: store) @@ -1441,11 +1422,11 @@ 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 { + @Test func terminalObservationCancellationWithoutParentFailsLoud() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1460,14 +1441,24 @@ struct CodexReviewStoreCommandTests { await backend.finishEvents(throwing: CancellationError()) 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) } } @@ -1487,12 +1478,17 @@ 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.") ))) } @@ -1511,23 +1507,151 @@ 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.")) _ = try await result } } + @Test func concurrentCancellationCallersJoinOneAcceptedOperation() async throws { + let backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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")) + 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 store = CodexReviewStore.makeTestingStore( @@ -1541,14 +1665,14 @@ 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.")) 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") } } @@ -1565,22 +1689,29 @@ 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(.interrupted(message: nil)) 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 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" }) @@ -1591,40 +1722,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 cancelDuringReviewStartupRetainsFailedUnpublishedCleanup() async throws { + let attempt = makeAttempt( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + turnID: "turn-1", + activeTurnThreadID: "review-thread-1" + ) + let backend = FakeCodexReviewBackend(nextAttempt: 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 throws { + @Test func closedSessionRejectsNewReviews() async { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) - try await withStoreCommandTestCleanup(backend: backend, store: store) { - await store.closeSession("session-1") + await withStoreCommandTestCleanup(backend: backend, store: store) { + _ = await store.closeSession("session-1") await #expect(throws: (any Error).self) { try await store.startReview( @@ -1635,45 +1827,150 @@ 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(nextAttempt: 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."), - workerDrainTimeout: .seconds(1) + 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(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(nextAttempt: 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-1", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) + ) + await backend.setNextAttempt(secondAttempt) + let second = try await store.startReview( + sessionID: "session-1", + request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) ) - #expect(running.core.lifecycle.status == .cancelled) - async let result = store.startReview( + let close = Task { @MainActor in + await store.closeSession("session-1") + } + try await backend.waitForInterruptReviews(count: 2, timeout: .seconds(2)) + + 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(nextAttempt: 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 backend = FakeCodexReviewBackend() + 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) + request: .init(cwd: "/tmp/project", target: .uncommittedChanges), + waitTimeout: .milliseconds(20) ) - await backend.yield(.completed(finalReview: "No issues found.")) - let read = try await result + _ = try await started - #expect(read.runID == "run-1") - #expect(read.core.lifecycle.status == .succeeded) + 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 closeActiveReviewSessionsBoundsStuckStartupDrain() async throws { + @Test func closeActiveReviewSessionsJoinsCancelledStartupWorker() async throws { let backend = FakeCodexReviewBackend() let startGate = AsyncGate() await backend.holdStartReview(with: startGate) @@ -1689,18 +1986,14 @@ struct CodexReviewStoreCommandTests { ) } try await backend.waitForStartReview(timeout: .seconds(2)) - let startedAt = ContinuousClock.now - let didDrain = await store.closeActiveReviewSessions( - reason: .system(message: "Account switched."), - workerDrainTimeout: .milliseconds(20) + reason: .system(message: "Account switched.") ) - #expect(didDrain == false) - #expect(ContinuousClock.now - startedAt < .seconds(1)) + #expect(didDrain) await startGate.open() let result = try await running.value - #expect(result.core.lifecycle.status == .cancelled) + #expect(result.presentation.status == .cancelled) } } @@ -1877,11 +2170,51 @@ 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 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( @@ -1955,7 +2288,7 @@ private actor ControlledTestSleeper { func sleep() async { if shouldBlock { - await gate.wait() + try? await gate.wait() } } } diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreRateLimitAutoRefreshTests.swift index 16887ed..889785b 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/ReviewRunRecordTests.swift b/Tests/CodexReviewKitTests/ReviewRunRecordTests.swift index ea05c23..d33bf74 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 0000000..b4bdb3b --- /dev/null +++ b/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift @@ -0,0 +1,522 @@ +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 backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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.")) + _ = 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 backend = FakeCodexReviewBackend() + 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.")) + _ = 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 backend = FakeCodexReviewBackend() + 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.")) + _ = 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 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 1ce991f..23d77b2 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -1,6 +1,7 @@ import Darwin import Foundation import MCP +import Synchronization @preconcurrency import NIOCore import Testing @_spi(Testing) @testable import CodexReviewKit @@ -69,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, @@ -84,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", ], @@ -111,6 +114,194 @@ 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 backend = FakeCodexReviewBackend() + 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 backend = FakeCodexReviewBackend() + 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() { @@ -133,6 +324,40 @@ 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 store = CodexReviewStore.makeTestingStore( @@ -143,7 +368,7 @@ struct CodexReviewMCPHTTPServerTests { try await withHTTPServer( store: store, logProjectionProvider: { result in - ReviewMCPLogProjection( + .available(ReviewMCPLogProjection( result: result, turnID: "turn-1", threadItems: [ @@ -161,8 +386,9 @@ struct CodexReviewMCPHTTPServerTests { text: "No issues found." )) ), - ] - ) + ], + reviewOutputText: "No issues found." + )) } ) { server in let endpoint = await server.url @@ -174,7 +400,6 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_start", "arguments": [ - "sessionID": "session-1", "cwd": "/tmp/project", "target": [ "type": "custom", @@ -214,7 +439,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.")) @@ -229,7 +454,23 @@ struct CodexReviewMCPHTTPServerTests { 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([ @@ -249,12 +490,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)) let resolved = try decodeSSEJSON(from: try await responseData) #expect( @@ -287,7 +523,7 @@ struct CodexReviewMCPHTTPServerTests { store: store, configuration: configuration, logProjectionProvider: { result in - ReviewMCPLogProjection( + .available(ReviewMCPLogProjection( result: result, turnID: "turn-1", threadItems: [ @@ -296,8 +532,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 @@ -373,7 +612,22 @@ struct CodexReviewMCPHTTPServerTests { 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([ @@ -405,7 +659,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.")) @@ -413,6 +667,33 @@ 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 store = CodexReviewStore.makeTestingStore( @@ -494,7 +775,12 @@ struct CodexReviewMCPHTTPServerTests { 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( @@ -502,7 +788,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( @@ -510,13 +801,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, @@ -527,7 +831,6 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_list", "arguments": [ - "sessionID": "other-session", "cwd": "/tmp/project", "statuses": ["succeeded"], ], @@ -546,14 +849,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( @@ -565,11 +888,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, @@ -644,7 +976,7 @@ struct CodexReviewMCPHTTPServerTests { } } - @Test func streamableHTTPReviewReadLeavesLogEmptyWithoutChatProvider() async throws { + @Test func streamableHTTPRejectsSucceededReviewWithoutChatProjection() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) @@ -657,13 +989,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, @@ -681,56 +1022,27 @@ 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 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( @@ -759,36 +1071,39 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPCancelsReviewByTransportScopedSelector() async throws { - let backend = FakeCodexReviewBackend() + let backend = FakeCodexReviewBackend( + nextAttempt: makeReviewAttemptForTesting( + attemptID: "attempt-1", + sourceThreadID: "thread-1", + activeTurnThreadID: "thread-1", + turnID: "turn-1", + model: "gpt-5" + ) + ) + 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) + 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, @@ -800,9 +1115,8 @@ struct CodexReviewMCPHTTPServerTests { "params": [ "name": "review_cancel", "arguments": [ - "sessionID": "other-session", "cwd": "/tmp/project", - "statuses": ["running"], + "statuses": ["queued", "running"], "reason": "Stop from MCP", ], ], @@ -811,23 +1125,33 @@ 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 streamableHTTPCancelDefaultsSelectorToActiveRunsInTransportSession() async throws { let backend = FakeCodexReviewBackend() let store = CodexReviewStore.makeTestingStore( - backend: TestingCodexReviewStoreBackend(reviewBackend: backend) + backend: TestingCodexReviewStoreBackend(reviewBackend: backend), + idGenerator: .init(next: { "run-running" }) ) try await withHTTPServer(store: store) { server in @@ -837,24 +1161,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( @@ -876,43 +1202,45 @@ 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 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) + ) + 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( @@ -943,24 +1271,16 @@ struct CodexReviewMCPHTTPServerTests { @Test func streamableHTTPCancelsDocumentedRunId() async throws { let backend = FakeCodexReviewBackend() 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( @@ -981,13 +1301,42 @@ 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 gate = AsyncGate() + let clock = ManualMCPHTTPServerClock() await backend.holdStartReview(with: gate) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -996,7 +1345,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) @@ -1019,7 +1383,32 @@ 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.")) let resolved = try decodeSSEJSON(from: try await responseData) @@ -1035,7 +1424,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, body: [ "jsonrpc": "2.0", - "id": 3, + "id": 4, "method": "tools/list", ] ) @@ -1045,13 +1434,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) @@ -1064,7 +1459,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, @@ -1082,6 +1479,7 @@ struct CodexReviewMCPHTTPServerTests { @Test func streamableHTTPExpiresSessionAfterEventStreamDisconnects() async throws { let backend = FakeCodexReviewBackend() + let clock = ManualMCPHTTPServerClock() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -1091,8 +1489,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 @@ -1104,7 +1504,9 @@ 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) } } @@ -1112,24 +1514,15 @@ struct CodexReviewMCPHTTPServerTests { @Test func streamableHTTPKeepsRunIDCancellationInTransportSession() async throws { let backend = FakeCodexReviewBackend() 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( @@ -1150,43 +1543,42 @@ 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 { + @Test func streamableHTTPDeleteFinishesItsRequestBeforeClosingStoreSession() async throws { let backend = FakeCodexReviewBackend() 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) } } @@ -1253,6 +1645,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), @@ -1272,9 +1696,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 } } @@ -1339,7 +1765,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) @@ -1414,6 +1845,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 } @@ -1437,6 +1883,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" @@ -1478,6 +1939,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)) } @@ -1490,6 +2103,14 @@ private nonisolated func testError(_ message: String) -> NSError { ) } +private func makeHTTPTestRunID(_ rawValue: String) -> ReviewRunID { + do { + return try ReviewRunID(validating: rawValue) + } catch { + preconditionFailure("Invalid explicit review run fixture: \(error)") + } +} + private func assertCompactLog(_ response: [String: Any], total: Int) { let log = response.value(for: ["result", "structuredContent", "log"]) as? [String: Any] #expect(log != nil) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index 6b51462..a17734b 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,13 +25,34 @@ 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 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", @@ -44,10 +68,90 @@ 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.finalLifecycleMessage == "Review completed.") + #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 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 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 0000000..13ff3eb --- /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 7a33749..3cda0f5 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -7,13 +7,14 @@ import CodexDataKit @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 == []) @@ -25,13 +26,15 @@ struct ReviewMCPLogProjectionTests { } @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 == []) @@ -42,15 +45,15 @@ struct ReviewMCPLogProjectionTests { } @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,7 +72,8 @@ struct ReviewMCPLogProjectionTests { kind: .commandExecution, content: .command(.init(command: "swift test", output: "passed")) ), - ] + ], + reviewOutputText: nil ) #expect(projection.orderedEntryIDs == [ @@ -85,27 +89,50 @@ struct ReviewMCPLogProjectionTests { } @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.finalLifecycleMessage == "Review completed.") #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 0000000..b4839e7 --- /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/ReviewMonitorChatLogTesting.swift b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift index b613213..0afd0f1 100644 --- a/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift +++ b/Tests/ReviewUITests/ReviewMonitorChatLogTesting.swift @@ -27,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 @@ -46,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? @@ -57,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, @@ -70,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 @@ -117,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 @@ -175,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, @@ -186,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, @@ -194,6 +198,7 @@ func makeReviewChatFixtureForTesting( preview: preview, model: model, workspaceCWD: cwd, + createdAt: resolvedCreatedAt, updatedAt: resolvedUpdatedAt, recencyAt: resolvedUpdatedAt, status: CodexThreadStatus(chatFixtureStatusForTesting: status) @@ -247,9 +252,9 @@ 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, @@ -261,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 @@ -274,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 } @@ -426,8 +432,8 @@ func makePreviewAppServerRuntimeForTesting( streamID: String, isRunning: Bool, initialSnapshot: CodexThreadSnapshot -) -> ReviewMonitorPreviewAppServerRuntime { - ReviewMonitorPreviewAppServerRuntime( +) -> PreviewRuntimeLifetime { + PreviewRuntimeLifetime( fixtures: [ makePreviewChatLogFixtureForTesting( chat: chat, @@ -552,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, @@ -619,12 +630,12 @@ private enum ReviewChatLogFixtureStore { @MainActor private final class ReviewChatLogFixtureRetainer { weak var store: CodexReviewStore? - let runtime: ReviewMonitorPreviewAppServerRuntime + let runtime: PreviewRuntimeLifetime private var cwdByChatID: [CodexThreadID: String] init( store: CodexReviewStore, - runtime: ReviewMonitorPreviewAppServerRuntime, + runtime: PreviewRuntimeLifetime, cwdByChatID: [CodexThreadID: String] ) { self.store = store @@ -1010,13 +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 ? .inProgress : nil - } - if rawValue == "running" { - return .inProgress - } - 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 ad48281..c127a2f 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -109,30 +109,17 @@ struct ReviewMonitorCodexChatDetailTests { @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", - state: .inProgress, - 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) @@ -150,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", - state: .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() @@ -195,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", - state: .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") } @@ -237,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", - state: .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) @@ -289,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) @@ -318,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", - state: .inProgress, - 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) @@ -370,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", - state: .inProgress, - 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) @@ -413,17 +335,13 @@ struct ReviewMonitorCodexChatDetailTests { } #expect(initialSnapshot.log.contains("Legacy fallback") == false) - try await runtime.transport.emitServerNotification( - method: "item/completed", - 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 ) ) @@ -440,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", - state: .inProgress, - 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) @@ -485,17 +390,13 @@ struct ReviewMonitorCodexChatDetailTests { let appendCount = transport.logAppendCountForTesting let reloadCount = transport.logReloadCountForTesting - try await runtime.transport.emitServerNotification( - method: "item/completed", - 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 ) ) @@ -542,7 +443,61 @@ 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 codexChatLogProjectionSuppressesEquivalentTypedReviewRolloutCompanion() async throws { var projection = ReviewMonitorCodexChatLogProjection() let finalReview = """ Review comment: @@ -559,10 +514,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 )) @@ -579,6 +534,117 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.blocks.count == 1) } + @Test func codexChatLogProjectionKeepsGeneralAssistantWithMatchingReviewText() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let finalReview = "No issues found." + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-review"), + state: .completed, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log(finalReview) + ), + .init( + id: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: finalReview + )) + ), + ] + ) + + let document = projection.render( + from: turn, + chatCreatedAt: nil, + chatUpdatedAt: nil + ) + + #expect(document?.blocks.count == 2) + #expect(document?.text == "\(finalReview)\n\n\(finalReview)") + } + + @Test func codexChatLogProjectionKeepsReviewRolloutCompanionWithDifferentText() async throws { + var projection = ReviewMonitorCodexChatLogProjection() + let turn = CodexTurnSnapshot( + id: CodexTurnID(rawValue: "turn-review"), + state: .completed, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log("Review failed before producing findings.") + ), + .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 == 2) + #expect(document?.text.contains("Review failed before producing findings.") == true) + #expect(document?.text.contains("The review child was interrupted.") == true) + } + + @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 codexChatLogProjectionKeepsMatchingReviewOutputsFromDifferentTurns() async throws { var projection = ReviewMonitorCodexChatLogProjection() let finalReview = "No issues found." @@ -595,10 +661,10 @@ struct ReviewMonitorCodexChatDetailTests { content: .log(finalReview) ), .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 )) @@ -615,10 +681,10 @@ struct ReviewMonitorCodexChatDetailTests { content: .log(finalReview) ), .init( - id: "review-output-agent-second", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "review-output-agent-second", + id: "review_rollout_assistant", role: .assistant, text: finalReview )) @@ -643,7 +709,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(renderedText == [finalReview, finalReview]) } - @Test func codexChatLogProjectionDeduplicatesSameTurnReasoningMirrorItems() async throws { + @Test func codexChatLogProjectionKeepsDistinctReasoningItemsRegardlessOfRawPayload() async throws { var projection = ReviewMonitorCodexChatLogProjection() let reasoningText = """ **Summarizing files with git** @@ -676,8 +742,9 @@ struct ReviewMonitorCodexChatDetailTests { ) 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 { @@ -793,7 +860,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 = """ @@ -856,12 +923,12 @@ 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( @@ -895,11 +962,11 @@ 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( @@ -928,11 +995,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 { @@ -961,8 +1028,8 @@ 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 { @@ -1423,33 +1490,207 @@ struct ReviewMonitorCodexChatDetailTests { #expect(thirdRange.lowerBound < movedSecondRange.lowerBound) } + @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.\n\nNo issues found." + ) + + 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 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 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) + )) + + 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", - state: .inProgress, - 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) @@ -1469,17 +1710,13 @@ struct ReviewMonitorCodexChatDetailTests { } #expect(transport.renderedStateForTesting.selection == .chat("chat-thread")) - try await runtime.transport.emitServerNotification( - method: "item/completed", - 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 ) ) @@ -1490,7 +1727,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( @@ -1501,45 +1738,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 - var completedAtMs: Int64 = 0 - - enum CodingKeys: String, CodingKey { - case threadID = "threadId" - case turnID = "turnId" - case item - case completedAtMs - } +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 + ) +} - struct Item: Encodable, Sendable { - var id: String - var type: String - var text: String? - var phase: String? - } +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 + ) +} + +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 8bd5492..e37ff32 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSelectionTitleResolverTests.swift @@ -19,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) ), ] @@ -39,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 @@ -65,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) ) ] @@ -78,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 @@ -90,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) ) ] @@ -115,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 @@ -162,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 a371778..ed79719 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexSidebarResultsTests.swift @@ -10,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() @@ -24,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))) @@ -70,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", @@ -104,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) @@ -147,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 { @@ -160,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) } @@ -233,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, @@ -251,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) } @@ -287,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() @@ -371,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), @@ -620,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, @@ -659,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) ), ] )) @@ -708,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() @@ -722,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) ), ] )) @@ -757,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() @@ -801,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( @@ -814,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( @@ -821,6 +855,7 @@ struct ReviewMonitorCodexSidebarResultsTests { workspace: secondRepo, name: "Second repo review", updatedAt: Date(timeIntervalSince1970: 4_000), + recencyAt: Date(timeIntervalSince1970: 4_000), status: .active(activeFlags: []) ), ] @@ -874,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) ), ] )) @@ -1111,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 ) @@ -1142,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 321625c..f635fa4 100644 --- a/Tests/ReviewUITests/ReviewUIShellTests.swift +++ b/Tests/ReviewUITests/ReviewUIShellTests.swift @@ -52,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() @@ -211,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) @@ -245,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) } @@ -307,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") } @@ -329,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) @@ -363,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]) } @@ -474,13 +503,15 @@ extension ReviewUITests { .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), @@ -528,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) } @@ -563,13 +594,15 @@ extension ReviewUITests { .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), @@ -580,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, @@ -628,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) } @@ -2073,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 @@ -2282,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 @@ -2399,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 f738917..db1cd00 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -24,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 { @@ -836,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( @@ -848,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."), @@ -932,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 @@ -942,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 @@ -1007,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 @@ -1063,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 @@ -1123,7 +1177,7 @@ struct ReviewUITests { text: "Sources/App.swift | 12 ++++++------", metadata: .init( title: "Updated Sources/App.swift", - commandStatus: "completed" + commandStatus: .completed ) ), to: chat.chatID, @@ -1164,7 +1218,7 @@ struct ReviewUITests { text: "Automatically compacting context", metadata: .init( sourceType: "contextCompaction", - status: "inProgress", + status: .inProgress, itemID: "compact_1" ) ) @@ -1202,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", @@ -1380,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) ), ] ) @@ -1476,10 +1530,10 @@ 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, @@ -2161,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 { @@ -2646,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, @@ -3195,7 +3249,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 @@ -5945,7 +5999,7 @@ final class CountingStartBackend: PreviewCodexReviewStoreBackend { startCalls += 1 } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { isActive = false } @@ -5981,7 +6035,7 @@ final class AuthActionBackend: PreviewCodexReviewStoreBackend { isActive = true } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { isActive = false } @@ -6023,13 +6077,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.") } @@ -6074,7 +6128,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { ) async { } - override func stop(store _: CodexReviewStore) async { + override func stop(store _: CodexReviewStore, purpose _: CodexReviewRuntimeStopPurpose) async { } override func waitUntilStopped() async {} @@ -6084,7 +6138,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextRefresh { shouldBlockNextRefresh = false await blockedRefreshStartedGate.open() - await blockedRefreshResumeGate.wait() + try? await blockedRefreshResumeGate.wait() } return currentSettingsSnapshot } @@ -6114,7 +6168,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextModelUpdate { shouldBlockNextModelUpdate = false await blockedModelUpdateStartedGate.open() - await blockedModelUpdateResumeGate.wait() + try? await blockedModelUpdateResumeGate.wait() } } @@ -6127,7 +6181,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { if shouldBlockNextReasoningUpdate { shouldBlockNextReasoningUpdate = false await blockedReasoningUpdateStartedGate.open() - await blockedReasoningUpdateResumeGate.wait() + try? await blockedReasoningUpdateResumeGate.wait() } } @@ -6143,7 +6197,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedRefreshToStart() async { - await blockedRefreshStartedGate.wait() + try? await blockedRefreshStartedGate.wait() } func resumeBlockedRefresh() async { @@ -6155,7 +6209,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedModelUpdateToStart() async { - await blockedModelUpdateStartedGate.wait() + try? await blockedModelUpdateStartedGate.wait() } func resumeBlockedModelUpdate() async { @@ -6167,7 +6221,7 @@ final class BlockingSettingsBackend: PreviewCodexReviewStoreBackend { } func waitForBlockedReasoningUpdateToStart() async { - await blockedReasoningUpdateStartedGate.wait() + try? await blockedReasoningUpdateStartedGate.wait() } func resumeBlockedReasoningUpdate() async { From 1e96a9095494d8f9824e74b7c5bfab949dec39d2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:00:04 +0900 Subject: [PATCH 28/62] docs(review): clarify retention source invariant --- .../CodexReviewAppServer/AppServerCodexReviewBackend.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 0d941e8..70089b2 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -232,6 +232,11 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { ) 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, From 1505795f4a21dc11bbb4ea534f597735703ad96e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:59:02 +0900 Subject: [PATCH 29/62] refactor(host): centralize runtime session ownership Own each accepted app-server generation, MCP lifetime, event consumers, lifecycle callback, and shared teardown in HostRuntimeSession. Preserve final retention during staged shutdown and reject late generation publication. --- .../LiveCodexReviewStoreBackend.swift | 673 ++++++++++++++---- .../CodexReviewHostTests.swift | 443 ++++++++++-- 2 files changed, 951 insertions(+), 165 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 79a966d..15343c7 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -33,6 +33,14 @@ private enum RuntimeReviewCleanupMode { case connectionTerminated } +private struct HostRuntimeConsumerFailure: Error, LocalizedError, Sendable { + let message: String + + var errorDescription: String? { + message + } +} + package struct CodexReviewMCPPortOwner: Equatable, Sendable { package var processIdentifier: Int32 package var command: String? @@ -159,12 +167,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let seed: CodexReviewStoreSeed - private var appServer: CodexAppServer? - private var appServerModelContainer: CodexModelContainer? - private var appServerBackend: AppServerCodexReviewBackend? - private var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? + private var runtimeSession: HostRuntimeSession? + private var nextRuntimeGeneration: UInt64 = 1 private var loginSession: LoginSession? - private var authNotificationTask: Task? private var settingsSnapshot = CodexReviewSettings.Snapshot() private let codexHomeURL: URL private let mcpHTTPServerConfiguration: CodexReviewMCPHTTPServer.Configuration @@ -179,6 +184,22 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 var mcpHTTPServer: (any CodexReviewMCPHTTPServing)? { + runtimeSession?.activeMCPHTTPServer + } + init( environment: [String: String] = ProcessInfo.processInfo.environment, runtimePreferences: CodexReviewRuntime.Preferences = .defaults, @@ -399,32 +420,69 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { store.transitionToFailed(registryLoadFailure.localizedDescription) return } - if appServerBackend != nil, forceRestartIfNeeded == false { + if let session = runtimeSession, + session.isActive, + forceRestartIfNeeded == false { logger.info("Review runtime already has an app-server backend") store.transitionToRunning(serverURL: await mcpHTTPServer?.url) return } - if forceRestartIfNeeded { + if let session = runtimeSession { + if case .stopIncomplete = session.phase { + store.transitionToFailed( + "Review thread retention recovery is quarantined; the previous runtime cannot be replaced." + ) + return + } await stop(store: store, purpose: .runtimeRestartPreservingRuns) + guard runtimeSession == nil else { + store.transitionToFailed("The previous review runtime did not finish stopping.") + return + } } - var startedAppServer: CodexAppServer? - var startedHTTPServer: (any CodexReviewMCPHTTPServing)? + precondition(nextRuntimeGeneration < .max, "Host runtime generations must not wrap.") + let session = HostRuntimeSession( + generation: nextRuntimeGeneration, + lifecycleHandler: appServerLifecycleHandler + ) + nextRuntimeGeneration += 1 + runtimeSession = session do { - if mcpHTTPServerFactory != nil { - try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration) - } let runtime = try await appServerRuntimeFactory(codexHomeURL) + guard runtimeSession === session else { + await runtime.appServer.close() + return + } + do { + try session.requireHealthyStaging() + } catch { + await runtime.appServer.close() + return + } + 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) - await observeAuthNotifications(appServer: appServer, backend: backend, store: store) + try await installRuntimeConsumers( + session: session, + appServer: appServer, + store: store + ) + let authSnapshot = try await backend.readAuth() + try requireCurrentStagingSession(session) + try await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) + try requireCurrentStagingSession(session) + switch await store.recoverOrphanedReviewThreads() { + case .recovered, .cleanupIncomplete: + break + case .journalUnavailable(let message): + throw ReviewBackendFailure.retentionJournal(message: message) + } + try requireCurrentStagingSession(session) if let mcpHTTPServerFactory { + try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration) + try requireCurrentStagingSession(session) let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider( modelContext: modelContainer.mainContext ) @@ -433,37 +491,60 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpHTTPServerConfiguration, logProjectionProvider ) + session.installMCPHTTPServer(mcpHTTPServer) try await mcpHTTPServer.stage() - startedHTTPServer = mcpHTTPServer - self.mcpHTTPServer = mcpHTTPServer } - let authSnapshot = try await backend.readAuth() - try await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) - switch await store.recoverOrphanedReviewThreads() { - case .recovered, .cleanupIncomplete: - break - case .journalUnavailable(let message): - throw ReviewBackendFailure.retentionJournal(message: message) + try requireCurrentStagingSession(session) + let serverURL = await session.mcpHTTPServer?.url + try requireCurrentStagingSession(session) + store.transitionToRunning(serverURL: serverURL) + session.commit() + await session.mcpHTTPServer?.activate() + guard runtimeSession === session, session.isActive else { + return } - store.transitionToRunning(serverURL: await self.mcpHTTPServer?.url) - await self.mcpHTTPServer?.activate() await refreshSelectedAccountRateLimits(auth: store.auth) logger.info("Review runtime started") } catch { + 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 + } 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 + } 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.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 == false { + return + } + if runtimeSession === session { + runtimeSession = nil + store.transitionToFailed(failureMessage) + } } } + 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 runtimeStartupFailureMessage(for error: Error) async -> String { if let mcpHTTPServerError = error as? CodexReviewMCPHTTPServer.Error { switch mcpHTTPServerError { @@ -509,46 +590,75 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func stop(store: CodexReviewStore, purpose: CodexReviewRuntimeStopPurpose) async { - let appServer = appServer - let appServerBackend = appServerBackend - let mcpHTTPServer = mcpHTTPServer - let hasRuntimeState = appServer != nil || appServerBackend != nil || mcpHTTPServer != nil + let session = runtimeSession let loginSession = self.loginSession - guard hasRuntimeState || loginSession != nil else { + guard let session else { + _ = await loginSession?.terminate(reason: .storeStop) if purpose.retiresRuns { _ = await store.retireReviewRunsForFinalStoreStop() } return } logger.info("Stopping review runtime") - await mcpHTTPServer?.stop() - self.mcpHTTPServer = nil - if let appServerBackend { - let reason = ReviewCancellation.system(message: "Review runtime stopped.") + let task = session.requestStop(purpose: purpose) { session in + await self.performRuntimeStop( + session: session, + store: store, + reviewCleanupMode: .connected, + reviewCancellation: .system(message: "Review runtime stopped."), + loginTerminationReason: .storeStop + ) + } + if let task { + let didReleaseResources = await task.value + if didReleaseResources, runtimeSession === session { + runtimeSession = nil + } + } + } + + private func performRuntimeStop( + session: HostRuntimeSession, + store: CodexReviewStore, + reviewCleanupMode: RuntimeReviewCleanupMode, + reviewCancellation: ReviewCancellation, + loginTerminationReason: LoginTerminationReason + ) async -> Bool { + let runtime = session.runtime + await session.mcpHTTPServer?.stop() + if let appServerBackend = runtime?.backend { await cleanupActiveReviewsForRuntimeTeardown( store: store, appServerBackend: appServerBackend, - reason: reason, - mode: .connected + reason: reviewCancellation, + mode: reviewCleanupMode ) } - _ = await loginSession?.terminate(reason: .storeStop) - if purpose.retiresRuns { + _ = await loginSession?.terminate(reason: loginTerminationReason) + if let appServer = runtime?.appServer { + let retainedRestartIdentities = await appServer.discardAllPreparedReviewRestarts() + precondition( + retainedRestartIdentities.values.allSatisfy(\.isEmpty), + "Review workers must transfer every prepared-restart identity before runtime teardown." + ) + } + if session.shouldRetireRuns { guard await store.retireReviewRunsForFinalStoreStop() else { logger.error("Review runtime remains open because an unpersisted cleanup quarantine is unresolved") - return + return false } } - self.appServer = nil - clearAppServerModelContainer() - authNotificationTask?.cancel() - authNotificationTask = nil - self.appServerBackend = nil - await appServer?.close() + await session.cancelConsumersAndWait() + await runtime?.appServer.close() logger.info("Review runtime stopped") + return true } - func waitUntilStopped() async {} + func waitUntilStopped() async { + if let task = runtimeSession?.stopTask { + _ = await task.value + } + } func refreshSettings() async throws -> CodexReviewSettings.Snapshot { guard let appServerBackend else { @@ -1285,7 +1395,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func interruptReview(_ attempt: ReviewAttempt, reason: CodexReviewBackendModel.CancellationReason) async throws { - guard let appServerBackend else { + guard let appServerBackend = teardownAppServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } try await appServerBackend.interruptReview(attempt, reason: reason) @@ -1311,7 +1421,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { func discardPreparedReviewRestart( _ token: CodexReviewBackendModel.Review.RestartToken ) async -> [ReviewAttempt] { - guard let appServerBackend else { + guard let appServerBackend = teardownAppServerBackend else { preconditionFailure( "A prepared review restart must retain its matching app-server runtime until discard completes." ) @@ -1320,7 +1430,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func cleanupReview(_ attempt: ReviewAttempt) async { - guard let appServerBackend else { + guard let appServerBackend = teardownAppServerBackend else { return } await appServerBackend.cleanupReview(attempt) @@ -1330,7 +1440,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { _ attempts: [ReviewAttempt], additionalThreadIDs: [ReviewThreadID] ) async -> ReviewRetainedThreadCleanupResult { - guard let appServerBackend else { + guard let appServerBackend = teardownAppServerBackend else { let attemptFailures = attempts.flatMap { attempt -> [ReviewRetainedThreadCleanupFailure] in if attempt.threadIdentity.activeTurnThreadID == attempt.threadIdentity.sourceThreadID { return [.init( @@ -1451,87 +1561,125 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return auth.persistedAccounts.first(where: { $0.accountKey == persistedAccount.accountKey }) } - private func observeAuthNotifications( + private func installRuntimeConsumers( + session: HostRuntimeSession, appServer: CodexAppServer, - backend: AppServerCodexReviewBackend, store: CodexReviewStore - ) async { - authNotificationTask?.cancel() - let stream = await appServer.accountEvents() - authNotificationTask = Task { @MainActor [weak self, weak store] in - guard let self, let store else { - return - } - 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 + } + 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, + 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 loginSession = self.loginSession - guard appServer != nil || appServerBackend != nil || mcpHTTPServer != nil || loginSession != nil else { + ) { + guard let session = runtimeSession, + session.generation == generation else { return } - let message = "Review runtime stopped unexpectedly: \(error.localizedDescription)" - let failedAppServer = appServer - let failedMCPHTTPServer = mcpHTTPServer - await failedMCPHTTPServer?.stop() - mcpHTTPServer = nil - if let appServerBackend { - let reason = ReviewCancellation.system(message: message) - await cleanupActiveReviewsForRuntimeTeardown( - store: store, - appServerBackend: appServerBackend, - reason: reason, - mode: .connectionTerminated - ) + switch session.phase { + case .staging: + session.recordStagingFailure(failure) + case .active: + let message = "Review runtime stopped unexpectedly: \(failure.message)" + store.transitionToFailed(message) + _ = session.requestStop(purpose: .runtimeRestartPreservingRuns) { session in + let didReleaseResources = await self.performRuntimeStop( + session: session, + store: store, + reviewCleanupMode: .connectionTerminated, + reviewCancellation: .system(message: message), + loginTerminationReason: .runtimeFailure(.runtime(message: message)) + ) + if didReleaseResources, self.runtimeSession === session { + self.runtimeSession = nil + } + return didReleaseResources + } + case .stopping, .stopIncomplete, .stopped: + return } - _ = await loginSession?.terminate( - reason: .runtimeFailure(.runtime(message: message)) - ) - appServer = nil - clearAppServerModelContainer() - appServerBackend = nil - authNotificationTask = nil - store.transitionToFailed(message) - await failedAppServer?.close() } private func handleAuthNotification( _ event: CodexAccountEvent, + generation: UInt64, backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel + store: CodexReviewStore ) async { switch event { case .accountUpdated: if loginSession != nil { return } - await refreshAuthAfterAccountNotification(backend: backend, auth: auth) + await refreshAuthAfterAccountNotification( + generation: generation, + backend: backend, + store: store + ) case .rateLimitsUpdated(let rateLimits): - await applyRateLimitsUpdatedNotification(rateLimits, auth: auth) + guard acceptsRuntimeEvent(generation: generation) else { + return + } + await applyRateLimitsUpdatedNotification(rateLimits, auth: store.auth) case .malformed(let method, let message): logger.error("Malformed account notification \(method, privacy: .public): \(message, privacy: .public)") case .unknown: @@ -1540,15 +1688,44 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func refreshAuthAfterAccountNotification( + generation: UInt64, backend: AppServerCodexReviewBackend, - auth: CodexReviewAuthModel + store: CodexReviewStore ) async { do { - try await applyAuthSnapshot(try await backend.readAuth(), to: auth) - await refreshSelectedAccountRateLimits(auth: auth) + let snapshot = try await backend.readAuth() + guard acceptsRuntimeEvent(generation: generation) else { + return + } + do { + try await accountRuntimeTransitionCoordinator.perform { + guard self.acceptsRuntimeEvent(generation: generation) else { + return + } + try await self.applyAuthSnapshotSerialized(snapshot, to: store.auth) + } + } catch CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication { + logger.debug("Dropping an account notification while an account transition owns publication") + return + } + guard acceptsRuntimeEvent(generation: generation) else { + return + } + await refreshSelectedAccountRateLimits(auth: store.auth) } catch { - auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) + guard acceptsRuntimeEvent(generation: generation) else { + return + } + store.auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) + } + } + + private func acceptsRuntimeEvent(generation: UInt64) -> Bool { + guard let session = runtimeSession, + session.generation == generation else { + return false } + return session.isActive } private func applyRateLimitsUpdatedNotification( @@ -1805,6 +1982,254 @@ private struct AppServerRuntime: Sendable { var backend: AppServerCodexReviewBackend } +@MainActor +private 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 let lifecycleHandler: CodexReviewAppServerLifecycleHandler? + private var didPublishLifecycle = false + private var stagingFailure: HostRuntimeConsumerFailure? + + init( + generation: UInt64, + lifecycleHandler: CodexReviewAppServerLifecycleHandler? + ) { + self.generation = generation + self.lifecycleHandler = lifecycleHandler + } + + var activeRuntime: AppServerRuntime? { + guard case .active = phase else { + return nil + } + return runtime + } + + var activeMCPHTTPServer: (any CodexReviewMCPHTTPServing)? { + guard case .active = phase else { + return nil + } + return mcpHTTPServer + } + + var isActive: Bool { + if case .active = phase { + return true + } + return false + } + + 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 installConsumers( + accountEvents: CodexAccountEvents, + connectionEvents: CodexConnectionEvents, + accountEventSink: @escaping @MainActor @Sendable (CodexAccountEvent) async -> Void, + exitSink: @escaping @MainActor @Sendable (HostRuntimeConsumerFailure) -> 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 { + exitSink(.init(message: "The Codex account event stream ended unexpectedly.")) + } + } catch is CancellationError { + } catch { + logger.error("Auth notification stream ended: \(error.localizedDescription, privacy: .public)") + 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): + exitSink(.init(message: Self.failureMessage(for: termination))) + return + } + } + if Task.isCancelled == false { + 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 + 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 + } + 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 -> Bool? { + guard let stopTask else { + return nil + } + return await stopTask.value + } + + 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 + phase = .stopped + } else { + phase = .stopIncomplete + } + } + + func requestStop( + purpose: CodexReviewRuntimeStopPurpose, + _ operation: @escaping @MainActor @Sendable (HostRuntimeSession) async -> Bool + ) -> Task? { + if purpose.retiresRuns { + shouldRetireRuns = true + } + 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 { @MainActor [weak self] in + guard let self else { + return true + } + let didReleaseResources = await operation(self) + self.stopTask = nil + self.finishStopping(didReleaseResources: didReleaseResources) + return didReleaseResources + } + 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." + } + } + } +} + @MainActor private struct LoginRuntime: Sendable { let appServer: CodexAppServer diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index f6dbf41..9c6bc93 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -232,6 +232,262 @@ struct CodexReviewHostTests { #expect(observedLifecycleStates == [true, false]) } + @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 liveStorePassesRuntimePreferenceMCPPortAndPathToHTTPServerFactory() async throws { let homeURL = try temporaryHome() let transport = FakeCodexAppServerTransport() @@ -272,11 +528,14 @@ 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), @@ -296,15 +555,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 @@ -314,9 +577,11 @@ 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( @@ -334,13 +599,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 @@ -693,7 +958,9 @@ struct CodexReviewHostTests { async let cancel: Void = store.cancelAuthentication() await transport.waitForRequest(.accountLoginCancel) async let stop: Void = store.stop() - await waitUntil { appServerLifecycleStates == [true, false] } + try #require(await waitUntil(timeout: .seconds(2)) { + appServerLifecycleStates == [true, false] + }) #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) await cancelGate.open() @@ -782,9 +1049,9 @@ struct CodexReviewHostTests { await loginStartGate.open() try await firstLogin.value - await waitUntil { + 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" @@ -925,9 +1192,9 @@ struct CodexReviewHostTests { .write(to: capturedRefreshCodexHomeURL.appendingPathComponent("auth.json")) await refreshGate.open() await refresh - await waitUntil { + 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) @@ -1060,10 +1327,10 @@ struct CodexReviewHostTests { planType: .plus )) await transport.waitForRequestCount(7) - await waitUntil { + 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") @@ -1281,7 +1548,9 @@ struct CodexReviewHostTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.attempt?.turnID.rawValue == "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 @@ -1344,7 +1613,9 @@ struct CodexReviewHostTests { sessionID: "session-1", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) ) - await waitUntil { store.reviewRuns.first?.core.attempt?.turnID.rawValue == "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 @@ -1570,20 +1841,24 @@ struct CodexReviewHostTests { 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) - await waitUntil { + 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.") @@ -1591,6 +1866,10 @@ struct CodexReviewHostTests { } #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 { @@ -1684,15 +1963,15 @@ struct CodexReviewHostTests { #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) await mainTransport.failConnection(.closed) - await waitUntil { + 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) @@ -1780,6 +2059,7 @@ struct CodexReviewHostTests { )) 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) @@ -1821,6 +2101,11 @@ struct CodexReviewHostTests { try Data(contentsOf: accountMutationJournalURL(homeURL: homeURL)) == journalData ) + try await firstTransport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + await firstTransport.waitForRequest(.accountRead, count: 2) let recoveryHomeURL = try temporaryHome() let recoveryAccountsURL = recoveryHomeURL @@ -2052,10 +2337,12 @@ struct CodexReviewHostTests { ) 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(\.request.operation) == [ .initialize, @@ -2180,7 +2467,8 @@ private func makeHostRateLimits( private func makeHostStoredThread( id: CodexThreadID, - model: String = "gpt-5" + model: String = "gpt-5", + turns: [CodexAppServerTestTurn] = [] ) throws -> CodexAppServerTestStoredThread { let workspace = URL(fileURLWithPath: "/tmp/project", isDirectory: true) return try .init( @@ -2194,9 +2482,9 @@ private func makeHostStoredThread( updatedAt: Date(timeIntervalSince1970: 2), status: .idle, ephemeral: false, - turns: [] + turns: turns.map(\.snapshot) ), - turns: [], + turns: turns, metadata: .init( sessionID: "session-\(id.rawValue)", cliVersion: "host-test-cli", @@ -2387,11 +2675,59 @@ private func failedMessage(from phase: CodexReviewAuthModel.Phase) -> String? { 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 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 stopGate: CodexAppServerTestGate? + private let state = MCPHTTPServerProbeState() + + init( + endpoint: URL, + stageFailure: MCPHTTPServerProbeError? = nil, + stageGate: CodexAppServerTestGate? = nil, + stopGate: CodexAppServerTestGate? = nil + ) { self.endpoint = endpoint + self.stageFailure = stageFailure + self.stageGate = stageGate + self.stopGate = stopGate } var url: URL { @@ -2400,23 +2736,48 @@ 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() + } + + 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 From 5853f3e03159584b654c0262b7fb73cd33e8d7bc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:16:00 +0900 Subject: [PATCH 30/62] refactor(auth): install login session before async binding --- .../LiveCodexReviewStoreBackend.swift | 427 ++++++------------ Sources/CodexReviewHost/LoginSession.swift | 337 ++++++++++++++ .../CodexReviewHostTests.swift | 181 +++++++- 3 files changed, 646 insertions(+), 299 deletions(-) create mode 100644 Sources/CodexReviewHost/LoginSession.swift diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 15343c7..03443e6 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -948,87 +948,127 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { throw CodexReviewAuthenticationFailure.alreadyInProgress } let mutationLease = try await accountRegistry.beginAuthenticationMutation() - var runtimeRequiringCleanup: LoginRuntime? - var mutationLeaseRequiringRelease: AccountRegistryStore.MutationLease? = mutationLease - var sessionOwnsFailurePublication = false - do { - let runtime = try await loginRuntime(for: purpose) - runtimeRequiringCleanup = runtime - let handle = try await runtime.appServer.loginChatGPT( - accountReadinessTimeout: .seconds(10) - ) - let generationID = UUID() - let session = LoginSession( - generationID: generationID, - purpose: purpose, - handle: handle, - runtime: runtime, - mutationLease: mutationLease, - rootOperation: { @MainActor [weak self] in - let observation: LoginRootObservation - do { - observation = .outcome(try await handle.result()) - } catch is CancellationError { - observation = .waiterCancelled(message: nil) - } catch { - observation = .failure( - .runtime(message: error.localizedDescription) - ) - } + 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, + 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, - handleID: handle.id + generationID: generationID ) return observation - }, - terminationHandler: { @MainActor [weak self, auth] session, reason, observation in - guard let self else { - return .stopped - } - return await self.finishLoginSession( - session, - reason: reason, - observation: observation, - auth: auth + } + let runtime: LoginRuntime + do { + runtime = try await runtimeProvider(purpose) + } catch { + let failure = (error as? CodexReviewAuthenticationFailure) + ?? .runtime(message: error.localizedDescription) + 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) + await startCompletion.resolve(.failure(failure)) + return finish(.failure(failure)) } - ) - loginSession = session - runtimeRequiringCleanup = nil - mutationLeaseRequiringRelease = nil - sessionOwnsFailurePublication = true - auth.updatePhase(.signingIn(.init( - title: "Sign in to Codex", - detail: "Continue signing in with your browser.", - browserURL: handle.authenticationURL.absoluteString, - userCode: nil - ))) - session.activate() - do { - try await externalURLOpener(handle.authenticationURL) - } catch { - let failure = CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) - let terminal = await session.terminate(reason: .urlOpenFailure(failure)) - switch terminal { - case .succeeded: - return - case .failed(let terminalFailure): - throw terminalFailure - case .cancelled, .stopped: - return + + let handleDisposition = await operationState.bind( + handle: handle, + runtime: runtime + ) + auth?.updatePhase(.signingIn(.init( + title: "Sign in to Codex", + detail: "Continue signing in with your browser.", + browserURL: handle.authenticationURL.absoluteString, + userCode: nil + ))) + + 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)) + } } + + do { + try await urlOpener(handle.authenticationURL) + await startCompletion.resolve(.success(())) + } catch { + let failure = CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) + await startCompletion.resolve(.failure(failure)) + do { + switch try await handle.cancel(acknowledgementTimeout: .seconds(5)) { + case .succeeded, + .authenticationCommittedNeedsConnectionReconciliation: + return finish(.outcome(try await handle.result())) + case .failed(let message): + return finish(.outcome(.failed(message: message))) + case .cancelled: + return finish(.failure(failure)) + } + } catch { + 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 .stopped + } + return await self.finishLoginSession( + session, + reason: reason, + observation: observation, + auth: auth + ) } - } catch { - await closeLoginRuntime(runtimeRequiringCleanup) - if let mutationLeaseRequiringRelease { - await accountRegistry.finishMutation(mutationLeaseRequiringRelease) - } - let failure = (error as? CodexReviewAuthenticationFailure) - ?? .runtime(message: error.localizedDescription) - if sessionOwnsFailurePublication == false { - auth.updatePhase(.failed(failure)) - } + ) + // The session owns the mutation lease and cancellation intent before either + // runtime acquisition or account/login/start can suspend. + loginSession = session + let startResult = await session.activate() + if case .failure(let failure) = startResult { + _ = await session.terminate(reason: .rootOutcome) if case .addAccountPreservingActive = purpose { throw failure } @@ -1037,12 +1077,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func publishLoginRootObservation( _ observation: LoginRootObservation, - generationID: UUID, - handleID: CodexLoginHandle.ID + generationID: UUID ) { guard let session = loginSession, - session.generationID == generationID, - session.handle.id == handleID else { + session.generationID == generationID else { return } session.publishRootObservation(observation) @@ -1132,6 +1170,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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? defer { if let stagingURLRequiringRemoval { @@ -1139,11 +1184,11 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } do { - let snapshot = try await session.runtime.backend.readAuth() + let snapshot = try await loginRuntime.backend.readAuth() let isolatedRateLimits: CodexRateLimits? - if session.runtime.usesPrimaryRuntime == false { - isolatedRateLimits = try? await session.runtime.backend.readRateLimits() - guard let runtime = session.takeOwnedRuntimeForClose() else { + if loginRuntime.usesPrimaryRuntime == false { + isolatedRateLimits = try? await loginRuntime.backend.readRateLimits() + guard let runtime = await session.takeOwnedRuntimeForClose() else { preconditionFailure("An isolated login runtime can be closed only once.") } await runtime.appServer.close() @@ -1155,9 +1200,9 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { snapshot, to: auth, activation: session.purpose.activation, - authSourceCodexHomeURL: session.runtime.codexHomeURL + authSourceCodexHomeURL: loginRuntime.codexHomeURL ) - if session.runtime.usesPrimaryRuntime == false { + if loginRuntime.usesPrimaryRuntime == false { if let account, let isolatedRateLimits { applyRateLimits(isolatedRateLimits, to: account) try await accountRegistry.updateCachedRateLimits( @@ -1342,7 +1387,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func closeLoginRuntimeIfNeeded(_ session: LoginSession) async { - guard let runtime = session.takeOwnedRuntimeForClose() else { + guard let runtime = await session.takeOwnedRuntimeForClose() else { return } await closeLoginRuntime(runtime) @@ -2230,15 +2275,7 @@ private final class HostRuntimeSession { } } -@MainActor -private struct LoginRuntime: Sendable { - let appServer: CodexAppServer - let backend: AppServerCodexReviewBackend - let codexHomeURL: URL - let usesPrimaryRuntime: Bool -} - -private actor AccountRegistryStore { +actor AccountRegistryStore { struct Snapshot: Sendable { let accounts: [CodexSavedAccountPayload] let activeAccountKey: String? @@ -2436,210 +2473,6 @@ private final class AccountRuntimeTransitionCoordinator { } } -@MainActor -private final class LoginSession { - typealias RootOperation = @MainActor @Sendable () async -> LoginRootObservation - typealias TerminationHandler = @MainActor @Sendable ( - LoginSession, - LoginTerminationReason, - LoginRootObservation - ) async -> LoginSessionTerminal - - private enum State { - case active - case closing( - reason: LoginTerminationReason, - completion: Task - ) - case closed(LoginSessionTerminal) - } - - let generationID: UUID - let purpose: LoginPurpose - let handle: CodexLoginHandle - let runtime: LoginRuntime - private let mutationLease: AccountRegistryStore.MutationLease - private let rootOperation: RootOperation - private let terminationHandler: TerminationHandler - private var rootTask: Task? - private var state: State = .active - private var didTakeOwnedRuntime = false - private var didReleaseMutationLease = false - - init( - generationID: UUID, - purpose: LoginPurpose, - handle: CodexLoginHandle, - runtime: LoginRuntime, - mutationLease: AccountRegistryStore.MutationLease, - rootOperation: @escaping RootOperation, - terminationHandler: @escaping TerminationHandler - ) { - self.generationID = generationID - self.purpose = purpose - self.handle = handle - self.runtime = runtime - self.mutationLease = mutationLease - self.rootOperation = rootOperation - self.terminationHandler = terminationHandler - } - - func activate() { - precondition(rootTask == nil, "A login session root task can be activated only once.") - let rootOperation = rootOperation - rootTask = Task { @MainActor in - await rootOperation() - } - } - - func publishRootObservation(_: LoginRootObservation) { - guard case .active = state else { - return - } - _ = beginClosing(reason: .rootOutcome) - } - - func terminate(reason: LoginTerminationReason) async -> LoginSessionTerminal { - switch state { - case .active: - return await beginClosing(reason: reason).value - case .closing(_, let completion): - return await completion.value - case .closed(let terminal): - return terminal - } - } - - func takeOwnedRuntimeForClose() -> LoginRuntime? { - guard runtime.usesPrimaryRuntime == false, - didTakeOwnedRuntime == false else { - return nil - } - didTakeOwnedRuntime = true - return runtime - } - - func takeMutationLeaseForRelease() -> AccountRegistryStore.MutationLease? { - guard didReleaseMutationLease == false else { - return nil - } - didReleaseMutationLease = true - return mutationLease - } - - private func beginClosing( - reason: LoginTerminationReason - ) -> Task { - guard case .active = state else { - preconditionFailure("Only an active login session can begin termination.") - } - precondition(rootTask != nil, "A login session must be activated before 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 { - do { - _ = try await handle.cancel() - } 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() - } -} - -private enum LoginRootObservation: Sendable { - case outcome(CodexLoginOutcome) - case failure(CodexReviewAuthenticationFailure) - case waiterCancelled(message: String?) -} - -private 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 - } - } -} - -private enum LoginSessionTerminal: Equatable, Sendable { - case succeeded - case failed(CodexReviewAuthenticationFailure) - case cancelled - case stopped -} - -private enum LoginPurpose: Equatable, Sendable { - case signIn - case addAccountPreservingActive(String?) - - var activation: LoginActivation { - switch self { - case .signIn: - return .activateAuthenticatedAccount - case .addAccountPreservingActive(let activeAccountKey): - return .preserveActiveAccount(activeAccountKey) - } - } -} - -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 private extension AccountRegistryStore { diff --git a/Sources/CodexReviewHost/LoginSession.swift b/Sources/CodexReviewHost/LoginSession.swift new file mode 100644 index 0000000..dbe92dc --- /dev/null +++ b/Sources/CodexReviewHost/LoginSession.swift @@ -0,0 +1,337 @@ +import Foundation +import CodexAppServerKit +import CodexReviewKit +import CodexReviewAppServer + +struct LoginRuntime: Sendable { + let appServer: CodexAppServer + let backend: AppServerCodexReviewBackend + let codexHomeURL: URL + let usesPrimaryRuntime: Bool +} + +enum LoginPurpose: Equatable, Sendable { + case signIn + case addAccountPreservingActive(String?) + + var activation: LoginActivation { + switch self { + case .signIn: + return .activateAuthenticatedAccount + case .addAccountPreservingActive(let activeAccountKey): + return .preserveActiveAccount(activeAccountKey) + } + } +} + +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 + } + } + } +} + +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 LoginSessionTerminal: Equatable, Sendable { + case succeeded + case failed(CodexReviewAuthenticationFailure) + case cancelled + case stopped +} + +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 + } + + 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 + + func requestCancellation() -> CodexLoginHandle? { + cancellationRequested = true + guard cancellationClaimed == false else { + return nil + } + guard case .loginPending(_, let handle) = phase else { + return nil + } + cancellationClaimed = true + return handle + } + + 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 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 + 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 + + init( + generationID: UUID, + purpose: LoginPurpose, + mutationLease: AccountRegistryStore.MutationLease, + cancellationTimeout: Duration = .seconds(5), + rootOperation: @escaping RootOperation, + terminationHandler: @escaping TerminationHandler + ) { + self.generationID = generationID + self.purpose = purpose + 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): + 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 + } + + 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/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 9c6bc93..275d1f6 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -17,6 +17,7 @@ private extension CodexReviewStore { environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, externalURLOpener: @escaping ExternalURLOpener = { _ in }, + deadlineClock: CodexAppServerTestDeadlineClock? = nil, mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = nil, networkMonitor: any CodexReviewNetworkMonitoring = SystemCodexReviewNetworkMonitor(), @@ -38,7 +39,8 @@ private extension CodexReviewStore { transport: transport, configuration: .init(localProcess: .init( codexHomeURL: codexHomeURL - )) + )), + deadlineClock: deadlineClock ).server } ) @@ -49,6 +51,7 @@ private extension CodexReviewStore { environment: [String: String], runtimePreferences: CodexReviewRuntime.Preferences = .defaults, externalURLOpener: @escaping ExternalURLOpener = { _ in }, + deadlineClock: CodexAppServerTestDeadlineClock? = nil, mcpHTTPServerFactory: (@MainActor @Sendable ( CodexReviewStore, CodexReviewMCPHTTPServer.Configuration, @@ -77,7 +80,8 @@ private extension CodexReviewStore { transport: transport, configuration: .init(localProcess: .init( codexHomeURL: codexHomeURL - )) + )), + deadlineClock: deadlineClock ).server } ) @@ -972,6 +976,179 @@ struct CodexReviewHostTests { #expect(store.serverURL == nil) } + @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() + 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 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 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 deadlineClock = CodexAppServerTestDeadlineClock() + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": try temporaryHome().path], + deadlineClock: deadlineClock, + transport: transport + ) + + 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 + + #expect(store.auth.isAuthenticating == false) + #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) From c4330f78eb837a03139fbeff9f8b80848374df71 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:28:28 +0900 Subject: [PATCH 31/62] refactor(auth): derive account mutations from disk state --- .../LiveCodexReviewStoreBackend.swift | 473 +++++++++++------- Sources/CodexReviewHost/LoginSession.swift | 29 +- .../CodexReviewHostTests.swift | 130 ++++- 3 files changed, 415 insertions(+), 217 deletions(-) diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 03443e6..d0a73cd 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -736,18 +736,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { func signIn(auth: CodexReviewAuthModel) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() - try await beginStockLogin(auth: auth, purpose: .signIn) + try await beginStockLogin(auth: auth, request: .signIn) } func addAccount(auth: CodexReviewAuthModel) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() - let activeAccountKey = auth.persistedActiveAccountKey ?? auth.selectedAccount?.accountKey - try await beginStockLogin( - auth: auth, - purpose: activeAccountKey != nil - ? .addAccountPreservingActive(activeAccountKey) - : .signIn - ) + try await beginStockLogin(auth: auth, request: .addAccount) } func cancelAuthentication(auth: CodexReviewAuthModel) async { @@ -763,18 +757,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { - guard auth.persistedAccounts.contains(where: { $0.accountKey == accountKey }) else { - return - } - try await accountRegistry.activateAccount( - accountKey, - accounts: auth.persistedAccounts.map(savedAccountPayload(from:)) - ) - auth.applyPersistedAccountStates( - auth.persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: accountKey - ) - auth.selectPersistedAccount(auth.persistedAccounts.first(where: { $0.accountKey == accountKey })?.id) + let persisted = try await accountRegistry.activateAccount(accountKey) + applyAccountRegistrySnapshot(persisted, to: auth) auth.updatePhase(.signedOut) guard let attachedStore, appServerBackend != nil else { return @@ -790,38 +774,33 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { - let removedActiveAccount = auth.selectedAccount?.accountKey == accountKey - || auth.persistedActiveAccountKey == accountKey - let remaining = auth.persistedAccounts.filter { $0.accountKey != accountKey } - let activeAccountKey = auth.persistedActiveAccountKey == accountKey - ? nil - : auth.persistedActiveAccountKey + let before = try await accountRegistry.load() + let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) + guard before.accounts.contains(where: { $0.accountKey == normalizedAccountKey }) else { + return + } + let removedActiveAccount = before.activeAccountKey == normalizedAccountKey + let persisted: AccountRegistryStore.Snapshot if removedActiveAccount { let prepared = try await accountRegistry.prepareIrreversibleRemoval( - accounts: remaining.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey + accountKey: normalizedAccountKey ) do { if let appServerBackend { - _ = try await appServerBackend.logout(.init(accountKey)) + _ = try await appServerBackend.logout(.init(normalizedAccountKey)) } - try await accountRegistry.commitPreparedMutation(prepared) + persisted = try await accountRegistry.commitPreparedMutation(prepared) } catch { try await abortPreparedAccountMutation(prepared, after: error) } } else { - try await accountRegistry.saveAccounts( - remaining.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey + persisted = try await accountRegistry.removeInactiveAccount( + accountKey: normalizedAccountKey ) } - try await accountRegistry.removeSavedAccountDirectory(accountKey: accountKey) - auth.applyPersistedAccountStates( - remaining.map(savedAccountPayload(from:)), - activeAccountKey: activeAccountKey - ) + await accountRegistry.cleanupRemovedAccountDirectory(accountKey: normalizedAccountKey) + applyAccountRegistrySnapshot(persisted, to: auth) if removedActiveAccount { - auth.selectPersistedAccount(nil) auth.updatePhase(.signedOut) guard let attachedStore, appServerBackend != nil else { return @@ -841,28 +820,19 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { toIndex: Int ) async throws { try await withAccountMutation { - 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) - try await accountRegistry.saveAccounts( - accounts.map(savedAccountPayload(from:)), - activeAccountKey: auth.persistedActiveAccountKey + let persisted = try await accountRegistry.reorderAccount( + accountKey: accountKey, + toIndex: toIndex ) - auth.applyPersistedAccountStates(accounts.map(savedAccountPayload(from:))) + applyAccountRegistrySnapshot(persisted, to: auth) } } func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() try await withAccountMutation { - guard let account = auth.selectedAccount else { + let before = try await accountRegistry.load() + guard let accountKey = before.activeAccountKey else { auth.updatePhase(.signedOut) auth.selectPersistedAccount(nil) return @@ -873,23 +843,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { reason: .system(message: "Signed out.") ) } - let remaining = auth.persistedAccounts.filter { $0.accountKey != account.accountKey } let prepared = try await accountRegistry.prepareIrreversibleRemoval( - accounts: remaining.map(savedAccountPayload(from:)), - activeAccountKey: nil + accountKey: accountKey ) + let persisted: AccountRegistryStore.Snapshot do { if let appServerBackend { - _ = try await appServerBackend.logout(.init(account.accountKey)) + _ = try await appServerBackend.logout(.init(accountKey)) } - try await accountRegistry.commitPreparedMutation(prepared) + persisted = try await accountRegistry.commitPreparedMutation(prepared) } catch { try await abortPreparedAccountMutation(prepared, after: error) } - try await accountRegistry.removeSavedAccountDirectory(accountKey: account.accountKey) + await accountRegistry.cleanupRemovedAccountDirectory(accountKey: accountKey) + applyAccountRegistrySnapshot(persisted, to: auth) auth.updatePhase(.signedOut) - auth.selectPersistedAccount(nil) - auth.applyPersistedAccountStates(remaining.map(savedAccountPayload(from:)), activeAccountKey: nil) if shouldRecycleRuntime, let attachedStore { await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) await start(store: attachedStore, forceRestartIfNeeded: true) @@ -942,12 +910,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private func beginStockLogin( auth: CodexReviewAuthModel, - purpose: LoginPurpose + request: LoginRequest ) async throws { guard loginSession == nil else { throw CodexReviewAuthenticationFailure.alreadyInProgress } - let mutationLease = try await accountRegistry.beginAuthenticationMutation() + let authenticationMutation = try await accountRegistry.beginAuthenticationMutation( + request: request + ) + let mutationLease = authenticationMutation.lease + let purpose = authenticationMutation.purpose let generationID = UUID() let runtimeProvider: @MainActor @Sendable (LoginPurpose) async throws -> LoginRuntime = { [weak self] purpose in @@ -1534,6 +1506,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } + private func applyAccountRegistrySnapshot( + _ snapshot: AccountRegistryStore.Snapshot, + to auth: CodexReviewAuthModel + ) { + auth.applyPersistedAccountStates( + snapshot.accounts, + activeAccountKey: snapshot.activeAccountKey + ) + auth.selectPersistedAccount(snapshot.activeAccountKey) + } + @discardableResult private func applyAuthSnapshotSerialized( _ snapshot: CodexReviewBackendModel.Auth.Snapshot, @@ -1545,65 +1528,41 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), let account = Self.monitorAccount(from: backendAccount) else { - if case .activateAuthenticatedAccount = activation { - try await accountRegistry.saveAccounts( - auth.persistedAccounts.map(savedAccountPayload(from:)), - activeAccountKey: nil + guard case .activateAuthenticatedAccount = activation else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "An isolated successful login did not expose its authenticated account." ) - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - } else { - auth.updatePhase(.signedOut) } + let persisted = try await accountRegistry.deactivateAccount() + auth.applyPersistedAccountStates( + persisted.accounts, + activeAccountKey: persisted.activeAccountKey + ) + auth.selectPersistedAccount(nil) + auth.updatePhase(.signedOut) return nil } - var persistedAccountPayloads = auth.persistedAccounts.map(savedAccountPayload(from:)) - let existingAccount = auth.persistedAccounts.first(where: { $0.accountKey == account.accountKey }) - var authenticatedAccountPayload = savedAccountPayload(from: account) - if let existingAccount { - let existingPayload = savedAccountPayload(from: existingAccount) - authenticatedAccountPayload.rateLimits = existingPayload.rateLimits - authenticatedAccountPayload.lastRateLimitFetchAt = existingPayload.lastRateLimitFetchAt - authenticatedAccountPayload.lastRateLimitError = existingPayload.lastRateLimitError - } - if let index = persistedAccountPayloads.firstIndex(where: { $0.accountKey == account.accountKey }) { - persistedAccountPayloads[index] = authenticatedAccountPayload - } else { - persistedAccountPayloads.insert(authenticatedAccountPayload, at: 0) - } - let activeAccountKey = activation.resolvedActiveAccountKey( - authenticatedAccountKey: account.accountKey, - persistedAccounts: auth.persistedAccounts + (existingAccount == nil ? [account] : []) - ) - if authenticatedAccountPayload.kind == .chatGPT { - try await accountRegistry.commitAuthenticatedAccount( - authenticatedAccountPayload, - accounts: persistedAccountPayloads, - activeAccountKey: activeAccountKey, + let accountPayload = savedAccountPayload(from: account) + let persisted: AccountRegistryStore.Snapshot + if accountPayload.kind == .chatGPT { + persisted = try await accountRegistry.commitAuthenticatedAccount( + accountPayload, + activation: activation, authSourceCodexHomeURL: authSourceCodexHomeURL ) } else { - try await accountRegistry.saveAccounts( - persistedAccountPayloads, - activeAccountKey: activeAccountKey + persisted = try await accountRegistry.upsertAccount( + accountPayload, + activation: activation ) } - let persistedAccount: CodexReviewAccount - if let existingAccount { - existingAccount.updateEmail(account.email) - existingAccount.updateKind(account.kind, capabilities: account.capabilities) - existingAccount.updatePlanType(account.planType) - persistedAccount = existingAccount - } else { - persistedAccount = account - } auth.applyPersistedAccountStates( - persistedAccountPayloads, - activeAccountKey: activeAccountKey + persisted.accounts, + activeAccountKey: persisted.activeAccountKey ) - auth.selectPersistedAccount(activeAccountKey) - auth.updatePhase(auth.selectedAccount == nil ? .signedOut : .signedOut) - return auth.persistedAccounts.first(where: { $0.accountKey == persistedAccount.accountKey }) + auth.selectPersistedAccount(persisted.activeAccountKey) + auth.updatePhase(.signedOut) + return auth.persistedAccounts.first(where: { $0.accountKey == account.accountKey }) } private func installRuntimeConsumers( @@ -2289,6 +2248,11 @@ actor AccountRegistryStore { fileprivate let id: UUID } + struct AuthenticationMutation: Sendable { + let lease: MutationLease + let purpose: LoginPurpose + } + private enum MutationKind { case authentication case account @@ -2309,26 +2273,14 @@ actor AccountRegistryStore { try Disk.load(codexHomeURL: codexHomeURL) } - func saveAccounts( - _ accounts: [CodexSavedAccountPayload], - activeAccountKey: String? - ) throws { - try Disk.saveAccounts( - accounts, - activeAccountKey: activeAccountKey, - codexHomeURL: codexHomeURL - ) + func deactivateAccount() throws -> Snapshot { + try Disk.deactivateAccount(codexHomeURL: codexHomeURL) + return try Disk.load(codexHomeURL: codexHomeURL) } - func activateAccount( - _ accountKey: String, - accounts: [CodexSavedAccountPayload] - ) throws { - try Disk.activateAccount( - accountKey, - accounts: accounts, - codexHomeURL: codexHomeURL - ) + func activateAccount(_ accountKey: String) throws -> Snapshot { + try Disk.activateAccount(accountKey, codexHomeURL: codexHomeURL) + return try Disk.load(codexHomeURL: codexHomeURL) } func updateCachedRateLimits(from account: CodexSavedAccountPayload) throws { @@ -2350,43 +2302,76 @@ actor AccountRegistryStore { func commitAuthenticatedAccount( _ authenticatedAccount: CodexSavedAccountPayload, - accounts: [CodexSavedAccountPayload], - activeAccountKey: String?, + activation: LoginActivation, authSourceCodexHomeURL: URL? - ) throws { + ) throws -> Snapshot { try Disk.commitAuthenticatedAccount( authenticatedAccount, - accounts: accounts, - activeAccountKey: activeAccountKey, + activation: activation, authSourceCodexHomeURL: authSourceCodexHomeURL ?? codexHomeURL, codexHomeURL: codexHomeURL ) + return try Disk.load(codexHomeURL: codexHomeURL) + } + + func upsertAccount( + _ account: CodexSavedAccountPayload, + activation: LoginActivation + ) throws -> Snapshot { + try Disk.upsertAccount( + account, + activation: activation, + codexHomeURL: codexHomeURL + ) + return try Disk.load(codexHomeURL: codexHomeURL) } func prepareIrreversibleRemoval( - accounts: [CodexSavedAccountPayload], - activeAccountKey: String? + accountKey: String ) throws -> PreparedMutation { try Disk.prepareIrreversibleRemoval( - accounts: accounts, - activeAccountKey: activeAccountKey, + accountKey: accountKey, codexHomeURL: codexHomeURL ) } - func commitPreparedMutation(_ mutation: PreparedMutation) throws { + func commitPreparedMutation(_ mutation: PreparedMutation) throws -> Snapshot { try Disk.commitPreparedMutation(mutation, codexHomeURL: codexHomeURL) + return try Disk.load(codexHomeURL: codexHomeURL) } func abortPreparedMutation(_ mutation: PreparedMutation) throws { try Disk.abortPreparedMutation(mutation, codexHomeURL: codexHomeURL) } - func removeSavedAccountDirectory(accountKey: String) throws { - try Disk.removeSavedAccountDirectory( + func removeInactiveAccount(accountKey: String) throws -> Snapshot { + try Disk.removeInactiveAccount(accountKey: accountKey, codexHomeURL: codexHomeURL) + return try Disk.load(codexHomeURL: codexHomeURL) + } + + func reorderAccount(accountKey: String, toIndex: Int) throws -> Snapshot { + try Disk.reorderAccount( accountKey: accountKey, + toIndex: toIndex, codexHomeURL: codexHomeURL ) + return try Disk.load(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 copySavedAuth(accountKey: String, to destinationCodexHomeURL: URL) throws -> Bool { @@ -2398,7 +2383,7 @@ actor AccountRegistryStore { ) } - func beginAuthenticationMutation() throws -> MutationLease { + func beginAuthenticationMutation(request: LoginRequest) throws -> AuthenticationMutation { if let activeMutation { switch activeMutation.kind { case .authentication: @@ -2407,7 +2392,17 @@ actor AccountRegistryStore { throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } } - return installMutation(kind: .authentication) + let snapshot = try Disk.load(codexHomeURL: codexHomeURL) + let purpose: LoginPurpose = switch request { + case .signIn: + .signIn + case .addAccount: + snapshot.activeAccountKey == nil ? .signIn : .addAccountPreservingActive + } + return .init( + lease: installMutation(kind: .authentication), + purpose: purpose + ) } func beginAccountMutation() throws -> MutationLease { @@ -2429,9 +2424,9 @@ actor AccountRegistryStore { } private func requireNoAccountMutationForBackgroundPersistence() throws { - guard activeMutation?.kind != .account else { + guard activeMutation == nil else { throw CodexReviewAuthenticationFailure.accountCommit( - message: "Background account persistence is blocked while an account mutation is in progress." + message: "Background account metadata persistence is blocked while an account mutation or authentication is in progress." ) } } @@ -2676,31 +2671,13 @@ enum Disk { return .init(accounts: accounts, activeAccountKey: activeAccountKey) } - static func saveAccounts( - _ accounts: [CodexSavedAccountPayload], - activeAccountKey: String?, - codexHomeURL: URL - ) throws { - let existing = try loadRegistry(codexHomeURL: codexHomeURL) - let normalizedActiveAccountKey = activeAccountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { accountKey in - accounts.contains(where: { $0.accountKey == accountKey }) ? accountKey : nil - } - try saveRegistry( - .init( - schemaVersion: existing.schemaVersion, - generation: existing.generation, - contentHash: existing.contentHash, - activeAccountKey: normalizedActiveAccountKey, - accounts: mergedEntries( - accounts, - activeAccountKey: normalizedActiveAccountKey, - existing: existing.accounts - ) - ), - codexHomeURL: codexHomeURL - ) + static func deactivateAccount(codexHomeURL: URL) throws { + var registry = try loadRegistry(codexHomeURL: codexHomeURL) + guard registry.activeAccountKey != nil else { + return + } + registry.activeAccountKey = nil + try saveRegistry(registry, codexHomeURL: codexHomeURL) } private static func mergedEntries( @@ -2744,7 +2721,6 @@ enum Disk { static func activateAccount( _ accountKey: String, - accounts: [CodexSavedAccountPayload], codexHomeURL: URL ) throws { let targetAccountKey = CodexReviewAccount.normalizedEmail(accountKey) @@ -2762,11 +2738,11 @@ enum Disk { let desiredAuthData = try validatedAuthData(at: savedAuthURL) var desiredRegistry = beforeRegistry desiredRegistry.activeAccountKey = targetAccountKey - desiredRegistry.accounts = mergedEntries( - accounts, - activeAccountKey: targetAccountKey, - existing: beforeRegistry.accounts - ) + if let index = desiredRegistry.accounts.firstIndex(where: { + normalizedAccountKey(from: $0) == targetAccountKey + }) { + desiredRegistry.accounts[index].lastActivatedAt = Date() + } desiredRegistry = try nextRegistry(from: desiredRegistry) var journal = MutationJournal( id: UUID(), @@ -2791,21 +2767,25 @@ enum Disk { } static func prepareIrreversibleRemoval( - accounts: [CodexSavedAccountPayload], - activeAccountKey: String?, + accountKey: String, codexHomeURL: URL ) throws -> AccountRegistryStore.PreparedMutation { let beforeRegistry = try loadRegistry(codexHomeURL: codexHomeURL) - let normalizedActiveAccountKey = activeAccountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { key in accounts.contains(where: { $0.accountKey == key }) ? key : nil } + 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.activeAccountKey = normalizedActiveAccountKey - desiredRegistry.accounts = mergedEntries( - accounts, - activeAccountKey: normalizedActiveAccountKey, - existing: beforeRegistry.accounts - ) + 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( @@ -2901,8 +2881,7 @@ enum Disk { static func commitAuthenticatedAccount( _ authenticatedAccount: CodexSavedAccountPayload, - accounts: [CodexSavedAccountPayload], - activeAccountKey: String?, + activation: LoginActivation, authSourceCodexHomeURL: URL, codexHomeURL: URL ) throws { @@ -2935,9 +2914,24 @@ enum Disk { ) createdRevision = revision } - let normalizedActiveAccountKey = activeAccountKey - .map(CodexReviewAccount.normalizedEmail) - .flatMap { key in accounts.contains(where: { $0.accountKey == key }) ? key : nil } + 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( @@ -2969,6 +2963,86 @@ enum Disk { } } + static func upsertAccount( + _ account: CodexSavedAccountPayload, + activation: LoginActivation, + codexHomeURL: URL + ) 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 + ) + } + + static func removeInactiveAccount( + accountKey: String, + codexHomeURL: URL + ) 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) + } + + static func reorderAccount( + accountKey: String, + toIndex: Int, + codexHomeURL: URL + ) 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) + } + static func saveSharedAuth( from sourceCodexHomeURL: URL, for account: CodexSavedAccountPayload, @@ -3291,6 +3365,19 @@ enum Disk { 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) + } } } diff --git a/Sources/CodexReviewHost/LoginSession.swift b/Sources/CodexReviewHost/LoginSession.swift index dbe92dc..767b618 100644 --- a/Sources/CodexReviewHost/LoginSession.swift +++ b/Sources/CodexReviewHost/LoginSession.swift @@ -10,39 +10,28 @@ struct LoginRuntime: Sendable { let usesPrimaryRuntime: Bool } +enum LoginRequest: Sendable { + case signIn + case addAccount +} + enum LoginPurpose: Equatable, Sendable { case signIn - case addAccountPreservingActive(String?) + case addAccountPreservingActive var activation: LoginActivation { switch self { case .signIn: return .activateAuthenticatedAccount - case .addAccountPreservingActive(let activeAccountKey): - return .preserveActiveAccount(activeAccountKey) + case .addAccountPreservingActive: + return .preserveActiveAccount } } } 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 - } - } - } + case preserveActiveAccount } enum LoginRootObservation: Sendable { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 275d1f6..ad1711f 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -656,6 +656,124 @@ struct CodexReviewHostTests { #expect(providerAccount.capabilities.supportsRateLimitRefresh == false) } + @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 writeSavedAccountAuth(homeURL: homeURL, accountKey: "first@example.com") + try writeSavedAccountAuth(homeURL: homeURL, accountKey: "second@example.com") + let store = CodexReviewStore.makeLiveStoreForTesting( + 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) + + try await store.switchAccount(staleSecondAccount) + + #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 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"] + ) + 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 isolatedTransport = FakeCodexAppServerTransport() + try await isolatedTransport.enqueueChatGPTLogin( + loginID: "isolated-login", + authenticationURL: testAuthenticationURL + ) + try await isolatedTransport.enqueueChatGPTLoginCancellation(.canceled) + let store = CodexReviewStore.makeLiveStoreForTesting( + 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) + 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 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 liveStoreFailsFastForCorruptAccountRegistry() async throws { let homeURL = try temporaryHome() let registryURL = homeURL @@ -2558,14 +2676,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], 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) From 0847dfb595b1a52e24bafbf85133860981048456 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:43:58 +0900 Subject: [PATCH 32/62] build(deps): pin subscription lifecycle fix --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 86738a7..795c725 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 = "abe61366030e4d7fdf788fae62ada53d21b6cd62" +let codexKitFallbackRevision = "f5671710ca376aca301de119af27b2c91523ed39" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 66894ca0681b4e3fcc246b98ddb9f53eeff66c96 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:59:17 +0900 Subject: [PATCH 33/62] build(deps): pin login cancellation fix --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 795c725..7884a4d 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 = "f5671710ca376aca301de119af27b2c91523ed39" +let codexKitFallbackRevision = "e71cab8afb5613cc0e64d812616850d7345335fc" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 55bfa9598c6d050e167e5ac597660e7e169a8132 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:22:33 +0900 Subject: [PATCH 34/62] build(deps): pin required message identity --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 7884a4d..fee0296 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 = "e71cab8afb5613cc0e64d812616850d7345335fc" +let codexKitFallbackRevision = "feb8176e72c3655688859cdcc9e0e58bfd9e9e34" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From d799daa9f39bf49fcc40ed54920e7bee11dafc4b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:23:21 +0900 Subject: [PATCH 35/62] refactor(testing): require explicit review attempt plans Make fake review starts and restarts consume validated attempt fixtures only after successful admission. Remove implicit terminal attempt selection and migrate consumers to pass the same identity through planning, events, and projections. --- Sources/CodexReviewTesting/TestSupport.swift | 62 ++--- .../CodexReviewNetworkMonitoringTests.swift | 18 +- .../CodexReviewStoreCommandTests.swift | 241 ++++++++++++------ .../FakeCodexReviewBackendAttemptTests.swift | 95 +++++++ .../ReviewThreadRetentionRegistryTests.swift | 30 ++- .../CodexReviewMCPHTTPServerTests.swift | 131 ++++++++-- .../CodexReviewMCPServerTests.swift | 10 +- 7 files changed, 430 insertions(+), 157 deletions(-) create mode 100644 Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift diff --git a/Sources/CodexReviewTesting/TestSupport.swift b/Sources/CodexReviewTesting/TestSupport.swift index bdc40c3..33020eb 100644 --- a/Sources/CodexReviewTesting/TestSupport.swift +++ b/Sources/CodexReviewTesting/TestSupport.swift @@ -277,8 +277,8 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { private var settings: CodexReviewBackendModel.Settings.Snapshot private var auth: CodexReviewBackendModel.Auth.Snapshot private var commands: [Command] = [] - private var nextAttempt: ReviewAttempt - private var nextRecoveredAttempt: ReviewAttempt? + private var plannedAttempts: [ReviewAttempt] + private var plannedRecoveredAttempts: [ReviewAttempt] = [] private var discardedRestartAttempts: [ReviewAttempt] = [] private var preparedRestartTokens: [String: CodexReviewBackendModel.Review.RestartToken] = [:] private var interruptFailureMessage: String? @@ -314,16 +314,11 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { package init( settings: CodexReviewBackendModel.Settings.Snapshot = .init(), auth: CodexReviewBackendModel.Auth.Snapshot = .init(), - nextAttempt: ReviewAttempt = makeReviewAttemptForTesting( - attemptID: "attempt-1", - sourceThreadID: "thread-1", - activeTurnThreadID: "review-thread-1", - turnID: "turn-1" - ) + plannedAttempt: ReviewAttempt? = nil ) { self.settings = settings self.auth = auth - self.nextAttempt = nextAttempt + self.plannedAttempts = plannedAttempt.map { [$0] } ?? [] } package func recordedCommands() -> [Command] { @@ -400,12 +395,12 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { restartPreparedReviewIgnoresCancellation = true } - package func setNextRecoveredAttempt(_ attempt: ReviewAttempt) { - nextRecoveredAttempt = attempt + package func planNextRecoveredAttempt(_ attempt: ReviewAttempt) { + plannedRecoveredAttempts.append(attempt) } - package func setNextAttempt(_ attempt: ReviewAttempt) { - nextAttempt = attempt + package func planNextAttempt(_ attempt: ReviewAttempt) { + plannedAttempts.append(attempt) } package func setDiscardedRestartAttempts(_ attempts: [ReviewAttempt]) { @@ -640,6 +635,11 @@ 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 { if startReviewIgnoresCancellation { await startReviewGate.waitIgnoringCancellation() @@ -647,7 +647,13 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { try await startReviewGate.wait() } } - return backendAttempt(for: nextAttempt) + guard plannedAttempts.isEmpty == false else { + throw FakeCodexReviewBackendError( + message: "No review attempt remained planned when startReview completed." + ) + } + let plannedAttempt = plannedAttempts.removeFirst() + return backendAttempt(for: plannedAttempt) } package func interruptReview( @@ -718,16 +724,12 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { if let recoveryFailureMessage { throw FakeCodexReviewBackendError(message: recoveryFailureMessage) } - let interruptedAttempt = token.interruptedAttempt - let recoveredAttempt = - nextRecoveredAttempt - ?? makeReviewAttemptForTesting( - attemptID: "attempt-recovered", - sourceThreadID: interruptedAttempt.threadIdentity.sourceThreadID.rawValue, - activeTurnThreadID: interruptedAttempt.threadIdentity.activeTurnThreadID.rawValue, - turnID: "turn-recovered", - model: interruptedAttempt.model ?? request.model + guard plannedRecoveredAttempts.isEmpty == false else { + throw FakeCodexReviewBackendError( + message: "No review attempt was planned for restartPreparedReview." ) + } + let recoveredAttempt = plannedRecoveredAttempts.removeFirst() preparedRestartTokens.removeValue(forKey: token.id) return backendAttempt(for: recoveredAttempt) } @@ -780,18 +782,16 @@ package actor FakeCodexReviewBackend: CodexReviewBackend { ]) } - package func yield( - _ terminal: FakeReviewTerminal, for attempt: ReviewAttempt? = nil - ) async { - await terminalSource(for: attempt ?? nextAttempt).yield(terminal) + package func yield(_ terminal: FakeReviewTerminal, for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).yield(terminal) } - package func finishEvents(for attempt: ReviewAttempt? = nil) async { - await terminalSource(for: attempt ?? nextAttempt).finish() + package func finishEvents(for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).finish() } - package func finishEvents(throwing error: any Error, for attempt: ReviewAttempt? = nil) async { - await terminalSource(for: attempt ?? nextAttempt).finish(throwing: error) + package func finishEvents(throwing error: any Error, for attempt: ReviewAttempt) async { + await terminalSource(for: attempt).finish(throwing: error) } package func finishEventMailboxes() async { diff --git a/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift b/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift index 4a4adfc..38ec108 100644 --- a/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewNetworkMonitoringTests.swift @@ -21,7 +21,13 @@ struct CodexReviewNetworkMonitoringTests { } @Test func unexpectedNetworkSourceFinishFailsAnOtherwiseLiveReview() async throws { - let backend = FakeCodexReviewBackend() + 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), @@ -48,7 +54,13 @@ struct CodexReviewNetworkMonitoringTests { } @Test func terminalCandidateWinsWhenNetworkSourceFinishesAtTheSameBoundary() async throws { - let backend = FakeCodexReviewBackend() + 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), @@ -67,7 +79,7 @@ struct CodexReviewNetworkMonitoringTests { ) != nil ) - await backend.yield(.completed(finalReview: "No issues found.")) + await backend.yield(.completed(finalReview: "No issues found."), for: attempt) monitor.finish() let read = try await result diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index 380dad7..c6b8de7 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -24,7 +24,13 @@ struct CodexReviewStoreCommandTests { } @Test func reviewStartPublishesCompletedRunLifecycle() 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), clock: .init(now: { Date(timeIntervalSince1970: 1) }), @@ -35,7 +41,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) let read = try await result #expect(read.runID.rawValue == "run-1") @@ -65,7 +71,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -81,7 +88,7 @@ struct CodexReviewStoreCommandTests { #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: makeRunID("run-1"), @@ -93,7 +100,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -111,7 +119,7 @@ struct CodexReviewStoreCommandTests { 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.presentation.status == .succeeded) @@ -119,7 +127,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -149,7 +158,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -173,7 +183,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -216,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, @@ -229,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() @@ -244,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" }) @@ -258,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.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) ) @@ -274,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"]) @@ -290,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) ) @@ -299,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( @@ -319,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), @@ -336,7 +356,7 @@ struct CodexReviewStoreCommandTests { #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 } } @@ -397,7 +417,8 @@ struct CodexReviewStoreCommandTests { } @Test func newlyStartedReviewUsesSortOrderAboveCurrentMaximum() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "sort-order") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend) ) @@ -425,7 +446,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 #expect(store.listReviews(cwd: "/tmp/project").items.map(\.targetSummary).first == "Uncommitted changes") @@ -433,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" }) @@ -448,7 +475,7 @@ struct CodexReviewStoreCommandTests { 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) @@ -469,7 +496,8 @@ struct CodexReviewStoreCommandTests { } @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( @@ -510,7 +538,8 @@ struct CodexReviewStoreCommandTests { } @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( @@ -554,14 +583,15 @@ 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.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), @@ -585,7 +615,7 @@ struct CodexReviewStoreCommandTests { runID: makeRunID("run-1"), cancellation: .mcpClient(message: "Stop") ) - await backend.yield(.cancelled("Stop")) + await backend.yield(.cancelled("Stop"), for: attempt) _ = try await result } } @@ -605,8 +635,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let settleGate = AsyncGate() let sleeper = ControlledTestSleeper(gate: settleGate) @@ -669,8 +699,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -732,8 +762,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -777,8 +807,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -825,8 +855,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -870,8 +900,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) await backend.setDiscardedRestartAttempts([initialRun, recoveredRun]) let recoverGate = AsyncGate() await backend.holdRestartPreparedReview(with: recoverGate) @@ -937,8 +967,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let recoverGate = AsyncGate() await backend.holdRestartPreparedReviewIgnoringCancellation(with: recoverGate) let networkMonitor = ManualCodexReviewNetworkMonitor() @@ -1004,7 +1034,7 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1049,7 +1079,7 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: run) + let backend = FakeCodexReviewBackend(plannedAttempt: run) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -1092,7 +1122,7 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: run) + let backend = FakeCodexReviewBackend(plannedAttempt: run) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1141,7 +1171,7 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1170,7 +1200,8 @@ struct CodexReviewStoreCommandTests { } @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( @@ -1192,7 +1223,7 @@ struct CodexReviewStoreCommandTests { 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.presentation.status == .cancelled) @@ -1217,7 +1248,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" }) @@ -1241,7 +1273,7 @@ struct CodexReviewStoreCommandTests { } #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() @@ -1256,7 +1288,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" }) @@ -1271,7 +1304,7 @@ struct CodexReviewStoreCommandTests { 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.presentation.status == .cancelled) @@ -1280,7 +1313,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -1291,7 +1325,7 @@ 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.presentation.status == .failed) @@ -1299,7 +1333,8 @@ struct CodexReviewStoreCommandTests { } @Test func serverInterruptionWithoutPendingCancellationFailsReview() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "backend-interruption") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }) @@ -1314,7 +1349,7 @@ struct CodexReviewStoreCommandTests { .waitUntilRunStatus(.running, runID: "run-1") != nil ) - await backend.yield(.interrupted(message: nil)) + await backend.yield(.interrupted(message: nil), for: attempt) let read = try await result #expect(read.presentation.status == .failed) @@ -1324,7 +1359,8 @@ struct CodexReviewStoreCommandTests { } @Test func typedTerminalFailureSurvivesStoreCommit() async throws { - let backend = FakeCodexReviewBackend() + 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" }) @@ -1349,7 +1385,7 @@ struct CodexReviewStoreCommandTests { .waitUntilRunStatus(.running, runID: "run-1") != nil ) - await backend.yield(.failed(failure)) + await backend.yield(.failed(failure), for: attempt) let read = try await result #expect(read.presentation.status == .failed) @@ -1376,8 +1412,8 @@ struct CodexReviewStoreCommandTests { activeTurnThreadID: "review-thread-1", model: "gpt-5" ) - let backend = FakeCodexReviewBackend(nextAttempt: initialRun) - await backend.setNextRecoveredAttempt(recoveredRun) + let backend = FakeCodexReviewBackend(plannedAttempt: initialRun) + await backend.planNextRecoveredAttempt(recoveredRun) let networkMonitor = ManualCodexReviewNetworkMonitor() let outageSleepStarted = AsyncGate() let debounceGate = AsyncGate() @@ -1427,7 +1463,13 @@ struct CodexReviewStoreCommandTests { } @Test func terminalObservationCancellationWithoutParentFailsLoud() 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" }) @@ -1438,7 +1480,7 @@ 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.presentation.status == .failed) @@ -1463,7 +1505,13 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -1495,7 +1543,8 @@ struct CodexReviewStoreCommandTests { } @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), @@ -1519,13 +1568,14 @@ struct CodexReviewStoreCommandTests { #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 backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "concurrent-cancellation") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -1568,7 +1618,8 @@ struct CodexReviewStoreCommandTests { } @Test func cancellationCallerCanLeaveWithoutCancellingAcceptedOperation() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "departing-cancellation-caller") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -1615,7 +1666,8 @@ struct CodexReviewStoreCommandTests { } @Test func terminalCancellationWinnerIgnoresLateInterruptFailure() async throws { - let backend = FakeCodexReviewBackend() + 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") @@ -1637,7 +1689,7 @@ struct CodexReviewStoreCommandTests { cancellation: .mcpClient(message: "Stop") ) try await backend.waitForInterruptReview(timeout: .seconds(2)) - await backend.yield(.completed(finalReview: "Terminal won")) + await backend.yield(.completed(finalReview: "Terminal won"), for: attempt) try #require( await StoreSnapshotProbe(store: store) .waitUntilRunStatus(.cancelled, runID: "run-1") != nil @@ -1653,7 +1705,8 @@ struct CodexReviewStoreCommandTests { } @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" }) @@ -1668,7 +1721,7 @@ struct CodexReviewStoreCommandTests { 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.presentation.status == .cancelled) @@ -1677,7 +1730,8 @@ struct CodexReviewStoreCommandTests { } @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( @@ -1698,7 +1752,7 @@ struct CodexReviewStoreCommandTests { cancellation: .mcpClient(message: "Stop") ) try await backend.waitForInterruptReview(timeout: .seconds(2)) - await backend.yield(.interrupted(message: nil)) + await backend.yield(.interrupted(message: nil), for: attempt) await interruptGate.open() _ = try await cancel let read = try await result @@ -1709,7 +1763,13 @@ struct CodexReviewStoreCommandTests { } @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.holdStartReviewIgnoringCancellation(with: gate) let store = CodexReviewStore.makeTestingStore( @@ -1779,7 +1839,7 @@ struct CodexReviewStoreCommandTests { turnID: "turn-1", activeTurnThreadID: "review-thread-1" ) - let backend = FakeCodexReviewBackend(nextAttempt: attempt) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let gate = AsyncGate() await backend.holdStartReviewIgnoringCancellation(with: gate) await backend.failRetainedCleanup(message: "Delete failed") @@ -1834,7 +1894,7 @@ struct CodexReviewStoreCommandTests { turnID: "turn-1", activeTurnThreadID: "review-thread-1" ) - let backend = FakeCodexReviewBackend(nextAttempt: attempt) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let startGate = AsyncGate() await backend.holdStartReviewIgnoringCancellation(with: startGate) let store = CodexReviewStore.makeTestingStore( @@ -1882,7 +1942,7 @@ struct CodexReviewStoreCommandTests { turnID: "turn-2", activeTurnThreadID: "review-thread-2" ) - let backend = FakeCodexReviewBackend(nextAttempt: firstAttempt) + let backend = FakeCodexReviewBackend(plannedAttempt: firstAttempt) let interruptGate = AsyncGate() await backend.holdInterruptReview(with: interruptGate) let store = CodexReviewStore.makeTestingStore( @@ -1894,7 +1954,7 @@ struct CodexReviewStoreCommandTests { request: .init(cwd: "/tmp/project-1", target: .uncommittedChanges), waitTimeout: .milliseconds(20) ) - await backend.setNextAttempt(secondAttempt) + await backend.planNextAttempt(secondAttempt) let second = try await store.startReview( sessionID: "session-1", request: .init(cwd: "/tmp/project-2", target: .uncommittedChanges), @@ -1926,7 +1986,7 @@ struct CodexReviewStoreCommandTests { turnID: "turn-1", activeTurnThreadID: "review-thread-1" ) - let backend = FakeCodexReviewBackend(nextAttempt: attempt) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) weak var weakStore: CodexReviewStore? var store: CodexReviewStore? = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), @@ -1947,7 +2007,8 @@ struct CodexReviewStoreCommandTests { } @Test func closeActiveReviewSessionsCancelsRunsWithoutClosingMCPServerSession() async throws { - let backend = FakeCodexReviewBackend() + 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" }) @@ -1971,7 +2032,8 @@ struct CodexReviewStoreCommandTests { } @Test func closeActiveReviewSessionsJoinsCancelledStartupWorker() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeAttempt(fixtureID: "close-startup-worker") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let startGate = AsyncGate() await backend.holdStartReview(with: startGate) let store = CodexReviewStore.makeTestingStore( @@ -2193,6 +2255,15 @@ private func makeAttempt( ) } +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) diff --git a/Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift b/Tests/CodexReviewKitTests/FakeCodexReviewBackendAttemptTests.swift new file mode 100644 index 0000000..3edb250 --- /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/ReviewThreadRetentionRegistryTests.swift b/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift index b4bdb3b..3e137f4 100644 --- a/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift +++ b/Tests/CodexReviewKitTests/ReviewThreadRetentionRegistryTests.swift @@ -273,7 +273,8 @@ struct ReviewThreadRetentionRegistryTests { @Test func storeDoesNotPublishAttemptWhoseJournalCommitFailed() async throws { let journal = ControlledReviewThreadRetentionJournal() await journal.failReplacements("disk unavailable") - let backend = FakeCodexReviewBackend() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), @@ -299,7 +300,8 @@ struct ReviewThreadRetentionRegistryTests { @Test func doubleFailureClosesAcceptanceGateUntilCleanupRecovery() async throws { let journal = ControlledReviewThreadRetentionJournal() await journal.failReplacements("disk unavailable") - let backend = FakeCodexReviewBackend() + 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), @@ -330,7 +332,8 @@ struct ReviewThreadRetentionRegistryTests { @Test func preservingRestartKeepsThreadsAndFinalStopRetiresThem() async throws { let journal = ControlledReviewThreadRetentionJournal() - let backend = FakeCodexReviewBackend() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), @@ -342,7 +345,7 @@ struct ReviewThreadRetentionRegistryTests { 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 started await store.restart() @@ -369,7 +372,8 @@ struct ReviewThreadRetentionRegistryTests { @Test func finalCleanupFailureRetiresResolverButKeepsDurableTombstone() async throws { let journal = ControlledReviewThreadRetentionJournal() - let backend = FakeCodexReviewBackend() + 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), @@ -382,7 +386,7 @@ struct ReviewThreadRetentionRegistryTests { 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 started await store.stop() @@ -395,7 +399,8 @@ struct ReviewThreadRetentionRegistryTests { @Test func finalRetryDoesNotCleanPersistedQuarantineTwice() async throws { let journal = ControlledReviewThreadRetentionJournal() - let backend = FakeCodexReviewBackend() + let attempt = makeRetentionAttempt(runID: "run-1") + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), @@ -406,7 +411,7 @@ struct ReviewThreadRetentionRegistryTests { 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 started await backend.failRetainedCleanup(message: "delete failed") @@ -424,6 +429,15 @@ struct ReviewThreadRetentionRegistryTests { } } +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? diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 23d77b2..d81c466 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -220,7 +220,11 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPConcurrentStopJoinsOneResourceDrain() async throws { - let backend = FakeCodexReviewBackend() + 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" }) @@ -246,7 +250,11 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPGlobalStopDrainsActivePostAndEventStreamWriter() async throws { - let backend = FakeCodexReviewBackend() + 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( @@ -359,7 +367,11 @@ struct CodexReviewMCPHTTPServerTests { } @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" }) @@ -413,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) @@ -448,7 +460,11 @@ 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" }) @@ -490,7 +506,7 @@ struct CodexReviewMCPHTTPServerTests { sessionID: sessionID, bodyData: requestBody ) - await backend.yield(.completed(finalReview: reviewOutput)) + await backend.yield(.completed(finalReview: reviewOutput), for: attempt) let resolved = try decodeSSEJSON(from: try await responseData) #expect( @@ -508,7 +524,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" }) @@ -569,7 +589,7 @@ struct CodexReviewMCPHTTPServerTests { #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, @@ -606,7 +626,11 @@ struct CodexReviewMCPHTTPServerTests { } @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" }) @@ -650,7 +674,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") @@ -695,7 +719,11 @@ struct CodexReviewMCPHTTPServerTests { } @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" }) @@ -730,7 +758,8 @@ struct CodexReviewMCPHTTPServerTests { additionalDetails: "Retry later" ) ) - ) + ), + for: attempt ) let resolved = try decodeSSEJSON(from: try await responseData) @@ -1031,7 +1060,11 @@ struct CodexReviewMCPHTTPServerTests { } @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), idGenerator: .init(next: { "run-tool-progress" }) @@ -1071,15 +1104,14 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPCancelsReviewByTransportScopedSelector() async throws { - let backend = FakeCodexReviewBackend( - nextAttempt: makeReviewAttemptForTesting( - attemptID: "attempt-1", - sourceThreadID: "thread-1", - activeTurnThreadID: "thread-1", - turnID: "turn-1", - model: "gpt-5" - ) + 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), @@ -1096,6 +1128,10 @@ struct CodexReviewMCPHTTPServerTests { 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", request: .init(cwd: "/tmp/project", target: .uncommittedChanges) @@ -1148,7 +1184,11 @@ struct CodexReviewMCPHTTPServerTests { } @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), idGenerator: .init(next: { "run-running" }) @@ -1208,7 +1248,15 @@ struct CodexReviewMCPHTTPServerTests { } @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"]) @@ -1228,6 +1276,8 @@ struct CodexReviewMCPHTTPServerTests { 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) @@ -1269,7 +1319,11 @@ 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), idGenerator: .init(next: { "run-running" }) @@ -1334,7 +1388,11 @@ struct CodexReviewMCPHTTPServerTests { } @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) @@ -1410,7 +1468,7 @@ struct CodexReviewMCPHTTPServerTests { #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") @@ -1512,7 +1570,11 @@ struct CodexReviewMCPHTTPServerTests { } @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), idGenerator: .init(next: { "run-other-session" }) @@ -1551,7 +1613,11 @@ struct CodexReviewMCPHTTPServerTests { } @Test func streamableHTTPDeleteFinishesItsRequestBeforeClosingStoreSession() async throws { - let backend = FakeCodexReviewBackend() + let attempt = makeHTTPReviewAttempt( + attemptID: "attempt-delete-close", + turnID: "turn-delete-close" + ) + let backend = FakeCodexReviewBackend(plannedAttempt: attempt) let store = CodexReviewStore.makeTestingStore( backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-running" }) @@ -2111,6 +2177,15 @@ private func makeHTTPTestRunID(_ rawValue: String) -> ReviewRunID { } } +private func makeHTTPReviewAttempt(attemptID: String, turnID: String) -> ReviewAttempt { + makeReviewAttemptForTesting( + attemptID: attemptID, + sourceThreadID: "source-\(attemptID)", + activeTurnThreadID: "active-\(attemptID)", + turnID: turnID + ) +} + private func assertCompactLog(_ response: [String: Any], total: Int) { let log = response.value(for: ["result", "structuredContent", "log"]) as? [String: Any] #expect(log != nil) diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index a17734b..ae8efc1 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -37,7 +37,13 @@ struct CodexReviewMCPServerTests { } @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" }) @@ -59,7 +65,7 @@ struct CodexReviewMCPServerTests { 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 { From c1ca75ab04464aa6610613be022e7ca4b8e8e9ad Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:30:29 +0900 Subject: [PATCH 36/62] docs(architecture): unify review cleanup result --- Docs/rearchitecture-2026-07-10.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Docs/rearchitecture-2026-07-10.md b/Docs/rearchitecture-2026-07-10.md index debc49c..15c4dfb 100644 --- a/Docs/rearchitecture-2026-07-10.md +++ b/Docs/rearchitecture-2026-07-10.md @@ -637,6 +637,24 @@ package struct CodexDeadlineClock: Sendable { 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 { @@ -709,10 +727,11 @@ public actor CodexAppServer { ) async -> [CodexReviewIdentity] public func discardAllPreparedReviewRestarts() async -> [CodexThreadID: [CodexReviewIdentity]] + @discardableResult public func cleanupReview( _ identity: CodexReviewIdentity, additionalCleanupThreadIDs: [[CodexThreadID]] = [] - ) async + ) async -> CodexReviewCleanupResult public func forkThread( _ id: CodexThreadID, options: CodexThread.Options = .init() @@ -732,6 +751,8 @@ public actor CodexAppServer { } ``` +`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 @@ -3825,6 +3846,7 @@ Phase 4ではこのinventoryを作るのではなく、recursive `public/open` s | `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 | @@ -3850,7 +3872,7 @@ Phase 4ではこのinventoryを作るのではなく、recursive `public/open` s | `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` | current-v2/stock-login/typed-outcome contractへ全面移行 | 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` @@ -4132,7 +4154,7 @@ pinned upstreamは `review_rollout_assistant` が同一review-exit operationのc - `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する。internal 4 targetsはpublic 0、TextTransitionsはDebug/Release別のfrozen interfaceと一致する。`CodexReviewMCPServer`の未使用library productを追加しない。default acceptanceはlive binary/auth/networkへ依存しない。 +- 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だけが残る。 From 97f07bb125c5c32c117b858759339a2818f83504 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:41:36 +0900 Subject: [PATCH 37/62] test(api): freeze TextTransitions interfaces --- scripts/verify-text-transitions-api.sh | 88 ++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100755 scripts/verify-text-transitions-api.sh diff --git a/scripts/verify-text-transitions-api.sh b/scripts/verify-text-transitions-api.sh new file mode 100755 index 0000000..d4c5efa --- /dev/null +++ b/scripts/verify-text-transitions-api.sh @@ -0,0 +1,88 @@ +#!/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="1996a5c9d5bb57367caec91fdccae66608c68c2a1ac9d6d5b9f7c5bb36260555" +readonly RELEASE_API_SHA256="e749f705ed082ae4d54ecdc2a812add5aaf9efc02c13e656c7917dc5e27f392a" + +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 \ + -module-name TextTransitions \ + -swift-version 6 \ + -target "$TARGET" \ + -sdk "$SDK_PATH" \ + -Onone \ + -D SWIFT_PACKAGE \ + -D DEBUG \ + -enable-testing \ + -emit-module-path "$SCRATCH_DIRECTORY/debug/TextTransitions.swiftmodule" + +xcrun swiftc "${sources[@]}" \ + -parse-as-library \ + -emit-module \ + -module-name TextTransitions \ + -swift-version 6 \ + -target "$TARGET" \ + -sdk "$SDK_PATH" \ + -O \ + -D SWIFT_PACKAGE \ + -emit-module-path "$SCRATCH_DIRECTORY/release/TextTransitions.swiftmodule" + +dump_api() { + local configuration="$1" + + xcrun swift-api-digester \ + -dump-sdk \ + -module TextTransitions \ + -swift-only \ + -avoid-location \ + -avoid-tool-args \ + -sdk "$SDK_PATH" \ + -target "$TARGET" \ + -I "$SCRATCH_DIRECTORY/$configuration" \ + -o "$SCRATCH_DIRECTORY/$configuration.json" +} + +verify_api() { + local configuration="$1" + local expected_sha256="$2" + local actual_sha256 + + actual_sha256="$(shasum -a 256 "$SCRATCH_DIRECTORY/$configuration.json" | 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.json" >&2 + trap - EXIT + exit 1 + fi + + echo "TextTransitions $configuration API verified: $actual_sha256" +} + +dump_api debug +dump_api release +verify_api debug "$DEBUG_API_SHA256" +verify_api release "$RELEASE_API_SHA256" From ab871bb441b22aa0d9594db16f4043ecf7846643 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:47:10 +0900 Subject: [PATCH 38/62] build(deps): pin canonical review cleanup --- Package.swift | 2 +- Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index fee0296..5291a45 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 = "feb8176e72c3655688859cdcc9e0e58bfd9e9e34" +let codexKitFallbackRevision = "425bc5f4d92edcc4fdf75c0644a65c29471468c3" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index 70089b2..ccb0926 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -282,7 +282,7 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { attempts.allSatisfy { $0.threadIdentity.sourceThreadID == sourceThreadID }, "One retained review cleanup must contain a single source thread lifecycle." ) - let result = await appServer.cleanupReviewReportingFailures( + let result = await appServer.cleanupReview( firstAttempt.appServerReviewIdentity, additionalCleanupThreadIDs: attempts.dropFirst().map { attempt in attempt.appServerReviewIdentity.cleanupThreadIDs From 40ec4ff438c3396fd389596376c44d25f248a35c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:49:06 +0900 Subject: [PATCH 39/62] ci(api): verify TextTransitions interfaces --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d099a01..a23905a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,9 @@ jobs: xcodebuild -version xcodebuild -showsdks + - name: Verify TextTransitions public API + run: scripts/verify-text-transitions-api.sh + - name: Run Swift package tests run: swift test --build-system swiftbuild --no-parallel From 07c422e64b0840c67eca053589b34939bef847f5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:53:27 +0900 Subject: [PATCH 40/62] test(api): normalize generated interfaces --- scripts/verify-text-transitions-api.sh | 39 ++++++++++++-------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/scripts/verify-text-transitions-api.sh b/scripts/verify-text-transitions-api.sh index d4c5efa..84d5a92 100755 --- a/scripts/verify-text-transitions-api.sh +++ b/scripts/verify-text-transitions-api.sh @@ -6,8 +6,8 @@ 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="1996a5c9d5bb57367caec91fdccae66608c68c2a1ac9d6d5b9f7c5bb36260555" -readonly RELEASE_API_SHA256="e749f705ed082ae4d54ecdc2a812add5aaf9efc02c13e656c7917dc5e27f392a" +readonly DEBUG_API_SHA256="8b6f247ace04cfbdb38c131d935f9b8d6dcbfbb0409282ebb69d28a89aaa8a03" +readonly RELEASE_API_SHA256="51ef185f3a36321bc6038b578f0eb3e1b9260c4bfa7af4e7e9dfdc858082d16b" cleanup() { rm -rf "$SCRATCH_DIRECTORY" @@ -28,6 +28,9 @@ 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" \ @@ -35,33 +38,27 @@ xcrun swiftc "${sources[@]}" \ -Onone \ -D SWIFT_PACKAGE \ -D DEBUG \ - -enable-testing \ - -emit-module-path "$SCRATCH_DIRECTORY/debug/TextTransitions.swiftmodule" + -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 \ - -emit-module-path "$SCRATCH_DIRECTORY/release/TextTransitions.swiftmodule" + -D SWIFT_PACKAGE -dump_api() { +normalize_api() { local configuration="$1" - xcrun swift-api-digester \ - -dump-sdk \ - -module TextTransitions \ - -swift-only \ - -avoid-location \ - -avoid-tool-args \ - -sdk "$SDK_PATH" \ - -target "$TARGET" \ - -I "$SCRATCH_DIRECTORY/$configuration" \ - -o "$SCRATCH_DIRECTORY/$configuration.json" + sed '/^\/\/ swift-/d' \ + "$SCRATCH_DIRECTORY/$configuration/TextTransitions.swiftinterface" \ + > "$SCRATCH_DIRECTORY/$configuration.normalized.swiftinterface" } verify_api() { @@ -69,12 +66,12 @@ verify_api() { local expected_sha256="$2" local actual_sha256 - actual_sha256="$(shasum -a 256 "$SCRATCH_DIRECTORY/$configuration.json" | awk '{print $1}')" + 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.json" >&2 + echo "generated API: $SCRATCH_DIRECTORY/$configuration.normalized.swiftinterface" >&2 trap - EXIT exit 1 fi @@ -82,7 +79,7 @@ verify_api() { echo "TextTransitions $configuration API verified: $actual_sha256" } -dump_api debug -dump_api release +normalize_api debug +normalize_api release verify_api debug "$DEBUG_API_SHA256" verify_api release "$RELEASE_API_SHA256" From 780ea0919e641e7c531673cdf94b6ca424748517 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:14:39 +0900 Subject: [PATCH 41/62] refactor(api): close review core surface --- .../Model/CodexReviewAccount.swift | 52 +++++++++---------- .../Model/CodexReviewAuthModel.swift | 40 +++++++------- .../Model/CodexReviewServerState.swift | 8 +-- .../Model/ParsedReviewResult.swift | 48 ++++++++--------- .../Store/CodexReviewStore.swift | 30 +++++------ .../Store/CodexReviewStoreCancellation.swift | 2 +- .../Store/CodexReviewStoreDiagnostics.swift | 11 ---- .../Store/CodexReviewStoreTesting.swift | 12 ----- .../CodexReviewMonitorApp.swift | 16 +++--- 9 files changed, 98 insertions(+), 121 deletions(-) diff --git a/Sources/CodexReviewKit/Model/CodexReviewAccount.swift b/Sources/CodexReviewKit/Model/CodexReviewAccount.swift index 54e17c4..9fd90fb 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 44bc18f..2ec2c0d 100644 --- a/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift +++ b/Sources/CodexReviewKit/Model/CodexReviewAuthModel.swift @@ -1,7 +1,7 @@ import Foundation import Observation -public enum CodexReviewAuthenticationFailure: Error, Equatable, Sendable { +package enum CodexReviewAuthenticationFailure: Error, Equatable, Sendable { case alreadyInProgress case accountMutationBlockedByAuthentication case runtime(message: String) @@ -14,7 +14,7 @@ public enum CodexReviewAuthenticationFailure: Error, Equatable, Sendable { } extension CodexReviewAuthenticationFailure: LocalizedError { - public var errorDescription: String? { + package var errorDescription: String? { switch self { case .alreadyInProgress: "Authentication is already in progress." @@ -37,7 +37,7 @@ extension CodexReviewAuthenticationFailure: LocalizedError { @MainActor @Observable -public final class CodexReviewAuthModel { +package final class CodexReviewAuthModel { package enum PendingAccountAction: Equatable, Sendable { case switchAccount(accountKey: String) case signOutActiveAccount @@ -86,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, @@ -105,48 +105,48 @@ public final class CodexReviewAuthModel { } } - public enum Phase: Sendable, Equatable { + package enum Phase: Sendable, Equatable { case signedOut case signingIn(Progress) 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? 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? { + package var errorMessage: String? { guard case .failed(let failure) = phase else { return nil } diff --git a/Sources/CodexReviewKit/Model/CodexReviewServerState.swift b/Sources/CodexReviewKit/Model/CodexReviewServerState.swift index 5012521..3c2fd31 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 29631fe..fd4efb0 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/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 3403ff2..1c4e12e 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 @@ -78,7 +78,7 @@ public final class CodexReviewStore { runtimeState.signalCancellation() } - public static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore { + package static func makePreviewStore(diagnosticsURL: URL? = nil) -> CodexReviewStore { makePreviewStore(seed: .init(), diagnosticsURL: diagnosticsURL) } @@ -167,28 +167,28 @@ public final class CodexReviewStore { transitionToStopped() } - public func restart() async { + package func restart() async { await stop(purpose: .runtimeRestartPreservingRuns) await start(forceRestartIfNeeded: true) } - 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 throws { + package func signIn() async throws { try await backend.signIn(auth: auth) } - public func addAccount() async throws { + package func addAccount() async throws { try await backend.addAccount(auth: auth) } - public func cancelAuthentication() async { + package func cancelAuthentication() async { await backend.cancelAuthentication(auth: auth) } @@ -211,7 +211,7 @@ public final class CodexReviewStore { try await signIn() } - public func logout() async { + package func logout() async { if auth.isAuthenticating, auth.selectedAccount == nil { await cancelAuthentication() return @@ -225,7 +225,7 @@ public final class CodexReviewStore { } } - public func signOutActiveAccount() async throws { + package func signOutActiveAccount() async throws { try await backend.signOutActiveAccount(auth: auth) } @@ -417,15 +417,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/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 26812b5..762a21f 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -110,7 +110,7 @@ extension CodexReviewStore { ) } - public func cancelAllRunningReviewRuns( + package func cancelAllRunningReviewRuns( reason: String = "Cancellation requested." ) async throws { let cancellation = ReviewCancellation.system( diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreDiagnostics.swift index dd6d55c..3727cf5 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/CodexReviewStoreTesting.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreTesting.swift index bac56de..5bf555a 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/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index c49b6d4..fa7fc16 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, From 94f3644ff7607f3f262101d32bf5e98f619ff951 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:13:32 +0900 Subject: [PATCH 42/62] refactor(ui): narrow review composition surface --- .../ReviewMonitorCodexModelSource.swift | 8 ++++-- Sources/ReviewUI/ReviewMonitorUIState.swift | 2 +- .../ReviewMonitorWindowController.swift | 6 ++--- .../ReviewMonitorPreviewComposition.swift | 12 --------- .../ReviewMonitorPreviewContent.swift | 26 +++++++++---------- .../CodexReviewMonitorApp.swift | 2 +- 6 files changed, 24 insertions(+), 32 deletions(-) diff --git a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift index efe2f65..bacc3af 100644 --- a/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift +++ b/Sources/ReviewUI/ReviewMonitorCodexModelSource.swift @@ -4,12 +4,16 @@ 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/ReviewMonitorUIState.swift b/Sources/ReviewUI/ReviewMonitorUIState.swift index 1c7d708..7c8714a 100644 --- a/Sources/ReviewUI/ReviewMonitorUIState.swift +++ b/Sources/ReviewUI/ReviewMonitorUIState.swift @@ -4,7 +4,7 @@ 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 29312d0..1fc069c 100644 --- a/Sources/ReviewUI/ReviewMonitorWindowController.swift +++ b/Sources/ReviewUI/ReviewMonitorWindowController.swift @@ -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/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift index 1b2ae95..d9277ab 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewComposition.swift @@ -63,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 88ace51..efa0e84 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewContent.swift @@ -2,7 +2,7 @@ import Foundation import CodexAppServerKit import CodexAppServerKitTesting import CodexDataKit -@_spi(Testing) import CodexReviewKit +import CodexReviewKit import ReviewUI @MainActor @@ -10,7 +10,7 @@ public final class ReviewMonitorPreviewContentSource { public let store: CodexReviewStore private weak var lifetime: PreviewRuntimeLifetime? - public var codexModelSource: ReviewMonitorCodexModelSource { + package var codexModelSource: ReviewMonitorCodexModelSource { requireLifetime().modelSource } @@ -51,7 +51,7 @@ public final class ReviewMonitorPreviewContentSource { } @discardableResult - public func appendPreviewChatLogStreamTick( + package func appendPreviewChatLogStreamTick( after tick: Int = 0, emitsNotifications: Bool = false ) async -> Int { @@ -61,25 +61,25 @@ public final class ReviewMonitorPreviewContentSource { ) } - public func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { + package func snapshotForTesting(chatID: CodexThreadID) async -> CodexThreadSnapshot? { await requireLifetime().snapshotForTesting(chatID: chatID) } - public func observedTurnStateForTesting( + package func observedTurnStateForTesting( chatID: CodexThreadID ) -> CodexTurnSnapshot.State? { requireLifetime().observedTurnStateForTesting(chatID: chatID) } - public func interruptRequestCountForTesting() async -> Int { + package func interruptRequestCountForTesting() async -> Int { await requireLifetime().interruptRequestCountForTesting() } - public func turnCompletionNotificationCountForTesting() -> Int { + package func turnCompletionNotificationCountForTesting() -> Int { requireLifetime().turnCompletionNotificationCountForTesting() } - public func archiveRequestCountForTesting() async -> Int { + package func archiveRequestCountForTesting() async -> Int { await requireLifetime().archiveRequestCountForTesting() } @@ -237,7 +237,7 @@ public enum ReviewMonitorPreviewContent { let cycle: Int } - public static func makeStore() -> CodexReviewStore { + package static func makeStore() -> CodexReviewStore { makeStore(runtimeLifetime: nil) } @@ -269,11 +269,11 @@ public enum ReviewMonitorPreviewContent { ) } - public static func makeCommandOutputStore() -> CodexReviewStore { + package static func makeCommandOutputStore() -> CodexReviewStore { makeCommandOutputContentSource().store } - public static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { + package static func makeCommandOutputContentSource() -> ReviewMonitorPreviewContentSource { let cwd = "/path/to/workspace-alpha" let now = Date() let chatItems = makeCommandOutputPreviewChatLogItems(cwd: cwd) @@ -631,7 +631,7 @@ public enum ReviewMonitorPreviewContent { ] } - public static func makePreviewAccounts() -> [CodexReviewAccount] { + package static func makePreviewAccounts() -> [CodexReviewAccount] { [ makePreviewAccount( email: "workspace@example.com", @@ -648,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 { diff --git a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift index fa7fc16..59a60ce 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitor/CodexReviewMonitorApp.swift @@ -300,7 +300,7 @@ struct ReviewMonitorAppComposition { ) } return ReviewMonitorWindowController( - appStore: dependencies.store, + store: dependencies.store, codexModelSource: codexModelSource, showSettings: showSettings ) From 345a56aaa678883ca947fc780326ff72dfd710e4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:17:51 +0900 Subject: [PATCH 43/62] refactor(review): centralize worker clock Inject one monotonic ReviewWorkerClock for connectivity observations and recovery timers. Keep presentation dates separate and move recovery tests to the same deterministic clock seam. --- .../CodexReviewNetworkMonitoring.swift | 10 +-- .../Store/ReviewStoreWorker.swift | 74 +++++++++++++++---- .../Store/ReviewWorkerClock.swift | 32 ++++++++ .../CodexReviewHostTests.swift | 8 +- .../CodexReviewStoreCommandTests.swift | 68 +++++++++++++---- 5 files changed, 157 insertions(+), 35 deletions(-) create mode 100644 Sources/CodexReviewKit/Store/ReviewWorkerClock.swift diff --git a/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift b/Sources/CodexReviewKit/Network/CodexReviewNetworkMonitoring.swift index eab4275..42779cc 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 { diff --git a/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift b/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift index f093a26..7b9238c 100644 --- a/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift +++ b/Sources/CodexReviewKit/Store/ReviewStoreWorker.swift @@ -3,7 +3,7 @@ import Foundation package struct ReviewWorkerState: Sendable { package struct Outage: Sendable { package let epoch: UInt64 - package let observedAt: ContinuousClock.Instant + package let observedAt: ReviewWorkerClock.Instant package let presentationDate: Date } @@ -48,17 +48,20 @@ package struct ReviewWorkerConnectivitySnapshot: Sendable { } package let connectivity: Connectivity - package let observedAt: ContinuousClock.Instant + package let observedAt: ReviewWorkerClock.Instant package let presentationDate: Date - init(_ snapshot: CodexReviewNetworkSnapshot) { + init( + _ snapshot: CodexReviewNetworkSnapshot, + clock: ReviewWorkerClock + ) { switch snapshot.status { case .satisfied: connectivity = .satisfied case .unsatisfied, .requiresConnection: connectivity = .outage } - observedAt = ContinuousClock().now + observedAt = clock.now presentationDate = snapshot.observedAt } } @@ -398,7 +401,12 @@ struct ReviewStoreWorker { group.addTask { await observeTerminal(backendAttempt, generation: generation) } - addNetworkChild(to: &group, source: networkSource, generation: generation) + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) var networkPhase = initialNetworkPhase if case .pendingOutage(let outage, _) = networkPhase { addOutageTimer( @@ -471,7 +479,12 @@ struct ReviewStoreWorker { case .networkSnapshot(_, let snapshot): switch (networkPhase, snapshot.connectivity) { case (.satisfied, .satisfied): - addNetworkChild(to: &group, source: networkSource, generation: generation) + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) case (.satisfied, .outage): let outage = ReviewWorkerState.Outage( epoch: state.nextOutageEpoch, @@ -487,9 +500,19 @@ struct ReviewStoreWorker { generation: generation, outageEpoch: outage.epoch ) - addNetworkChild(to: &group, source: networkSource, generation: generation) + addNetworkChild( + to: &group, + source: networkSource, + clock: policy.clock, + generation: generation + ) case (.pendingOutage, .outage): - addNetworkChild(to: &group, source: networkSource, generation: generation) + 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 { @@ -650,7 +673,12 @@ struct ReviewStoreWorker { let policy = networkRecoveryPolicy let source = ReviewWorkerNetworkSource(monitor: networkMonitor) return await withTaskGroup(of: ReviewWorkerSignal.self) { group in - addNetworkChild(to: &group, source: source, generation: generation) + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) var connectivity = initialConnectivity if case .settling(let settleGeneration) = connectivity { addSettleTimer( @@ -681,7 +709,12 @@ struct ReviewStoreWorker { case .networkSnapshot(_, let snapshot): switch (connectivity, snapshot.connectivity) { case (.unsatisfied, .outage): - addNetworkChild(to: &group, source: source, generation: generation) + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) case (.unsatisfied(let next), .satisfied): connectivity = .settling(generation: next) state.stage = .waitingForNetwork( @@ -697,9 +730,19 @@ struct ReviewStoreWorker { outageEpoch: outage.epoch, settleGeneration: next ) - addNetworkChild(to: &group, source: source, generation: generation) + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) case (.settling, .satisfied): - addNetworkChild(to: &group, source: source, generation: generation) + addNetworkChild( + to: &group, + source: source, + clock: policy.clock, + generation: generation + ) case (.settling(let current), .outage): group.cancelAll() while await group.next() != nil {} @@ -1079,6 +1122,7 @@ private func observeTerminal( private func addNetworkChild( to group: inout TaskGroup, source: ReviewWorkerNetworkSource, + clock: ReviewWorkerClock, generation: UInt64 ) { group.addTask { @@ -1087,7 +1131,7 @@ private func addNetworkChild( } return .networkSnapshot( generation: generation, - ReviewWorkerConnectivitySnapshot(snapshot) + ReviewWorkerConnectivitySnapshot(snapshot, clock: clock) ) } } @@ -1100,7 +1144,7 @@ private func addOutageTimer( ) { group.addTask { do { - try await policy.sleep(policy.outageDebounce) + try await policy.clock.sleep(for: policy.outageDebounce) return .outageDebounceElapsed( generation: generation, outageEpoch: outageEpoch @@ -1123,7 +1167,7 @@ private func addSettleTimer( ) { group.addTask { do { - try await policy.sleep(policy.recoverySettle) + try await policy.clock.sleep(for: policy.recoverySettle) return .recoverySettleElapsed( generation: generation, outageEpoch: outageEpoch, diff --git a/Sources/CodexReviewKit/Store/ReviewWorkerClock.swift b/Sources/CodexReviewKit/Store/ReviewWorkerClock.swift new file mode 100644 index 0000000..8949aef --- /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/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index ad1711f..e8485c8 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -2080,6 +2080,7 @@ struct CodexReviewHostTests { @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()) @@ -2092,7 +2093,12 @@ struct CodexReviewHostTests { let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in }), + networkRecoveryPolicy: .init( + clock: .init( + now: { workerClock.now }, + sleep: { _ in } + ) + ), transport: transport ) diff --git a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift index c6b8de7..abda9be 100644 --- a/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift +++ b/Tests/CodexReviewKitTests/CodexReviewStoreCommandTests.swift @@ -537,6 +537,30 @@ struct CodexReviewStoreCommandTests { } } + @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 attempt = makeAttempt(fixtureID: "transient-outage") let backend = FakeCodexReviewBackend(plannedAttempt: attempt) @@ -549,7 +573,9 @@ struct CodexReviewStoreCommandTests { networkRecoveryPolicy: .init( outageDebounce: .seconds(10), recoverySettle: .seconds(1), - sleep: { _ in try? await debounceGate.wait() } + clock: testReviewWorkerClock { _ in + try? await debounceGate.wait() + } ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { @@ -597,7 +623,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( @@ -647,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) { @@ -706,7 +732,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( @@ -769,7 +795,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( @@ -816,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( @@ -862,7 +888,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( @@ -910,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( @@ -976,7 +1002,7 @@ struct CodexReviewStoreCommandTests { 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) { @@ -1040,7 +1066,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 running = store.startReview( @@ -1128,7 +1154,7 @@ struct CodexReviewStoreCommandTests { 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) { @@ -1177,7 +1203,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( @@ -1208,7 +1234,11 @@ struct CodexReviewStoreCommandTests { backend: TestingCodexReviewStoreBackend(reviewBackend: backend), idGenerator: .init(next: { "run-1" }), networkMonitor: networkMonitor, - networkRecoveryPolicy: .init(sleep: { _ in try? await debounceGate.wait() }) + networkRecoveryPolicy: .init( + clock: testReviewWorkerClock { _ in + try? await debounceGate.wait() + } + ) ) try await withStoreCommandTestCleanup(backend: backend, store: store) { async let result = store.startReview( @@ -1424,7 +1454,7 @@ struct CodexReviewStoreCommandTests { networkRecoveryPolicy: .init( outageDebounce: .seconds(10), recoverySettle: .seconds(1), - sleep: { _ in + clock: testReviewWorkerClock { _ in await outageSleepStarted.open() try? await debounceGate.wait() } @@ -2364,6 +2394,16 @@ private actor ControlledTestSleeper { } } +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 From f7d7b664130ac0e52c68528b7d9b00de726b3844 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:21:42 +0900 Subject: [PATCH 44/62] refactor(testing): use preview content factory --- .../CodexReviewMonitorCITests.swift | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift index 1d2ec4f..5076bcc 100644 --- a/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift +++ b/Tools/ReviewMonitor/CodexReviewMonitorCITests/CodexReviewMonitorCITests.swift @@ -107,7 +107,7 @@ struct CodexReviewMonitorCITests { NSApp.windowsMenu = previousWindowsMenu } - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedContext: ReviewMonitorLaunchContext? var capturedShowSettings: (@MainActor () -> Void)? let recorder = WindowControllerFactoryRecorder() @@ -172,7 +172,9 @@ struct CodexReviewMonitorCITests { let composition = ReviewMonitorAppComposition( makeDependencies: { _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() @@ -219,7 +221,9 @@ struct CodexReviewMonitorCITests { let settingsWindowController = CountingWindowController() let composition = ReviewMonitorAppComposition( makeDependencies: { _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() @@ -253,7 +257,7 @@ struct CodexReviewMonitorCITests { 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( @@ -279,7 +283,7 @@ struct CodexReviewMonitorCITests { 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( @@ -340,7 +344,7 @@ struct CodexReviewMonitorCITests { let runtimePreferencesStore = RuntimePreferencesStoreStub( preferences: expectedRuntimePreferences ) - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedRuntimePreferences: CodexReviewRuntime.Preferences? let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, @@ -364,7 +368,7 @@ struct CodexReviewMonitorCITests { @Test func liveCompositionPassesAppServerLifecycleHandlerToLiveStoreFactory() { let runtimePreferencesStore = RuntimePreferencesStoreStub() - let expectedStore = CodexReviewStore.makePreviewStore() + let expectedStore = ReviewMonitorPreviewContent.makeContentSource().store var capturedLifecycleHandler: CodexReviewAppServerLifecycleHandler? let composition = ReviewMonitorAppComposition.live( runtimePreferencesStore: runtimePreferencesStore, @@ -619,7 +623,9 @@ struct CodexReviewMonitorCITests { let composition = ReviewMonitorAppComposition( makeDependencies: { _ in - ReviewMonitorAppDependencies(store: CodexReviewStore.makePreviewStore()) + ReviewMonitorAppDependencies( + store: ReviewMonitorPreviewContent.makeContentSource().store + ) }, makeWindowController: { _, _ in CountingWindowController() From ec389b5f86d3f59771e393198a742e54b8db22ec Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:34:32 +0900 Subject: [PATCH 45/62] build(deps): pin routing and login fixes --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 5291a45..7d325c8 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 = "425bc5f4d92edcc4fdf75c0644a65c29471468c3" +let codexKitFallbackRevision = "1ee509b31660d2491c71be4931d9e515126eec08" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 9643aeeea53a3ebe82b91ae7400b9d68ae72bab1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:45:35 +0900 Subject: [PATCH 46/62] refactor(auth): own account runtime transitions Centralize account mutations, login cancellation handoffs, runtime generation publication, and durable registry commits under explicit owner contracts. Preserve preview cancellation semantics and cover the lifecycle races with focused integration tests. --- .../AccountRuntimeTransitionCoordinator.swift | 843 ++++ .../LiveCodexReviewStoreBackend.swift | 4072 ++++++++++++--- Sources/CodexReviewHost/LoginSession.swift | 175 +- .../Store/CodexReviewStore.swift | 30 +- .../Store/CodexReviewStoreBackend.swift | 34 + .../Store/CodexReviewStoreReviews.swift | 6 + .../PreviewCodexReviewStoreBackend.swift | 6 +- .../PreviewRuntimeLifetime.swift | 12 +- ...ReviewMonitorPreviewAppServerRuntime.swift | 148 +- .../CodexReviewHostTests.swift | 4467 ++++++++++++++--- 10 files changed, 8426 insertions(+), 1367 deletions(-) create mode 100644 Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift diff --git a/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift b/Sources/CodexReviewHost/AccountRuntimeTransitionCoordinator.swift new file mode 100644 index 0000000..afa0c1e --- /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/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index d0a73cd..ed5558d 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -10,21 +10,12 @@ import CodexReviewAppServer import CodexReviewMCPServer private let logger = Logger(subsystem: "CodexReviewKit", category: "live-store-backend") -package typealias ExternalURLOpener = @MainActor @Sendable (URL) async throws -> Void +package typealias ExternalURLOpener = @MainActor @Sendable (URL) throws -> Void public typealias CodexReviewAppServerLifecycleHandler = @MainActor @Sendable (CodexModelContainer?) -> Void private let defaultExternalURLOpener: ExternalURLOpener = { url in - try await withCheckedThrowingContinuation { continuation in - NSWorkspace.shared.open( - url, - configuration: .init() - ) { _, error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + guard NSWorkspace.shared.open(url) else { + throw CodexReviewAuthenticationFailure.urlOpen(url) } } @@ -41,6 +32,16 @@ private struct HostRuntimeConsumerFailure: Error, LocalizedError, Sendable { } } +private struct IsolatedLoginProductCommitCancelled: Error, Sendable {} + +private struct IsolatedLoginProductCommitFailure: Error, LocalizedError, Sendable { + let failure: CodexReviewAuthenticationFailure + + var errorDescription: String? { + failure.localizedDescription + } +} + package struct CodexReviewMCPPortOwner: Equatable, Sendable { package var processIdentifier: Int32 package var command: String? @@ -59,6 +60,17 @@ 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 } @@ -99,7 +111,17 @@ public extension CodexReviewStore { 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, @@ -110,6 +132,16 @@ public extension CodexReviewStore { 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 } ) } @@ -128,6 +160,16 @@ public extension CodexReviewStore { 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( @@ -139,6 +181,16 @@ public extension CodexReviewStore { mcpPortOwnerResolver: mcpPortOwnerResolver, mcpHTTPServerBindChecker: mcpHTTPServerBindChecker, 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) @@ -165,11 +217,108 @@ 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 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 @@ -178,6 +327,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { private let mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver private let mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker private let appServerRuntimeFactory: AppServerRuntimeFactory + 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? @@ -196,6 +349,15 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 } @@ -216,6 +378,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { mcpPortOwnerResolver: CodexReviewMCPPortOwnerResolver? = nil, mcpHTTPServerBindChecker: CodexReviewMCPHTTPServerBindChecker? = 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() }, appServerRuntimeFactory: AppServerRuntimeFactory? = nil ) { let runtimePreferences = runtimePreferences.normalized @@ -223,8 +395,17 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { runtimePreferences: runtimePreferences, environment: environment ) - accountRegistry = AccountRegistryStore(codexHomeURL: codexHomeURL) - accountRuntimeTransitionCoordinator = AccountRuntimeTransitionCoordinator() + 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, @@ -235,6 +416,10 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { self.mcpPortOwnerResolver = mcpPortOwnerResolver ?? Self.defaultMCPPortOwnerResolver self.mcpHTTPServerBindChecker = mcpHTTPServerBindChecker ?? Self.defaultMCPHTTPServerBindChecker self.appServerLifecycleHandler = appServerLifecycleHandler + self.appServerCloser = appServerCloser + self.authenticationHandleDidBind = authenticationHandleDidBind + self.finalRuntimeRetirementDidClaim = finalRuntimeRetirementDidClaim + self.reconciliationDebtDidClear = reconciliationDebtDidClear self.appServerRuntimeFactory = appServerRuntimeFactory ?? Self.makeAppServerRuntimeFactory( codexExecutablePath: runtimePreferences.codexExecutablePath ) @@ -254,12 +439,21 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 } @@ -413,52 +607,189 @@ 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 let registryLoadFailure { - store.auth.updatePhase(.failed(registryLoadFailure)) - store.transitionToFailed(registryLoadFailure.localizedDescription) - return + if shouldPublishRuntimeState(mode: mode) { + store.auth.updatePhase(.failed(registryLoadFailure)) + store.transitionToFailed(registryLoadFailure.localizedDescription) + } + return false } if let session = runtimeSession, session.isActive, - forceRestartIfNeeded == false { + 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 { - store.transitionToFailed( - "Review thread retention recovery is quarantined; the previous runtime cannot be replaced." - ) - return + 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 { - store.transitionToFailed("The previous review runtime did not finish stopping.") - return + if shouldPublishRuntimeState(mode: mode) { + store.transitionToFailed("The previous review runtime did not finish stopping.") + } + return false } } + if mode.explicitStart != nil, + shouldStageRuntimePublication(mode: mode) == false { + logger.info("Skipping a superseded runtime start before creating replacement resources") + return false + } precondition(nextRuntimeGeneration < .max, "Host runtime generations must not wrap.") let session = HostRuntimeSession( generation: nextRuntimeGeneration, - lifecycleHandler: appServerLifecycleHandler + lifecycleHandler: appServerLifecycleHandler, + finalRetirementDidClaim: finalRuntimeRetirementDidClaim ) nextRuntimeGeneration += 1 runtimeSession = session + var clearedDebtExpectation: ExpectedRuntimeAccount? do { + let persistedDebtExpectation = try await accountRegistry.reconciliationDebtExpectation() + let validationExpectation = expectedAccount ?? persistedDebtExpectation let runtime = try await appServerRuntimeFactory(codexHomeURL) guard runtimeSession === session else { - await runtime.appServer.close() - return + await appServerCloser(runtime.appServer) + return false } do { try session.requireHealthyStaging() } catch { - await runtime.appServer.close() - return + await appServerCloser(runtime.appServer) + return false } session.installRuntime(runtime) let appServer = runtime.appServer @@ -469,10 +800,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { appServer: appServer, store: store ) - let authSnapshot = try await backend.readAuth() - try requireCurrentStagingSession(session) - try await applyAuthSnapshotSerialized(authSnapshot, to: store.auth) - try requireCurrentStagingSession(session) switch await store.recoverOrphanedReviewThreads() { case .recovered, .cleanupIncomplete: break @@ -480,7 +807,8 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { throw ReviewBackendFailure.retentionJournal(message: message) } try requireCurrentStagingSession(session) - if let mcpHTTPServerFactory { + if shouldStageRuntimePublication(mode: mode), + let mcpHTTPServerFactory { try await mcpHTTPServerBindChecker(mcpHTTPServerConfiguration) try requireCurrentStagingSession(session) let logProjectionProvider = CodexReviewMCPServer.chatLogProjectionProvider( @@ -497,30 +825,114 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { try requireCurrentStagingSession(session) let serverURL = await session.mcpHTTPServer?.url try requireCurrentStagingSession(session) - store.transitionToRunning(serverURL: serverURL) - session.commit() - await session.mcpHTTPServer?.activate() - guard runtimeSession === session, session.isActive else { - return + 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 } - 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 + 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 + return false } logger.error("Review runtime failed to start: \(failureMessage, privacy: .public)") let stopTask = session.requestStop(purpose: .runtimeRestartPreservingRuns) { session in - await self.performRuntimeStop( + await self.accountRegistry.closeRuntimeAdmission(generation: session.generation) + return await self.performRuntimeStop( session: session, store: store, reviewCleanupMode: .connected, @@ -528,13 +940,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { loginTerminationReason: .runtimeFailure(.runtime(message: failureMessage)) ) } - if let stopTask, await stopTask.value == false { - return + if let stopTask, await stopTask.value.didReleaseResources == false { + return false } if runtimeSession === session { runtimeSession = nil - store.transitionToFailed(failureMessage) + if shouldPublishRuntimeState(mode: mode) { + store.transitionToFailed(failureMessage) + } } + return false } } @@ -545,6 +960,110 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 { if let mcpHTTPServerError = error as? CodexReviewMCPHTTPServer.Error { switch mcpHTTPServerError { @@ -590,18 +1109,50 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } 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) + } + + 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") + session.closeAdmission() let task = session.requestStop(purpose: purpose) { session in - await self.performRuntimeStop( + await self.accountRegistry.closeRuntimeAdmission(generation: session.generation) + return await self.performRuntimeStop( session: session, store: store, reviewCleanupMode: .connected, @@ -610,61 +1161,214 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } if let task { - let didReleaseResources = await task.value - if didReleaseResources, runtimeSession === session { - runtimeSession = nil - } + 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() } } - private func performRuntimeStop( + private func installRuntimeStopFollowup( + stopTask: Task, session: HostRuntimeSession, - store: CodexReviewStore, - reviewCleanupMode: RuntimeReviewCleanupMode, - reviewCancellation: ReviewCancellation, - loginTerminationReason: LoginTerminationReason - ) async -> Bool { - let runtime = session.runtime - await session.mcpHTTPServer?.stop() - if let appServerBackend = runtime?.backend { - await cleanupActiveReviewsForRuntimeTeardown( - store: store, - appServerBackend: appServerBackend, - reason: reviewCancellation, - mode: reviewCleanupMode - ) - } - _ = await loginSession?.terminate(reason: loginTerminationReason) - if let appServer = runtime?.appServer { - let retainedRestartIdentities = await appServer.discardAllPreparedReviewRestarts() - precondition( - retainedRestartIdentities.values.allSatisfy(\.isEmpty), - "Review workers must transfer every prepared-restart identity before runtime teardown." - ) + store: CodexReviewStore + ) -> Task { + if let runtimeStopFollowup = runtimeStopFollowups[session.generation] { + return runtimeStopFollowup } - if session.shouldRetireRuns { - guard await store.retireReviewRunsForFinalStoreStop() else { - logger.error("Review runtime remains open because an unpersisted cleanup quarantine is unresolved") - return false + 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 } - } - await session.cancelConsumersAndWait() - await runtime?.appServer.close() - logger.info("Review runtime stopped") - return true - } + 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() + if let appServerBackend = runtime?.backend { + 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) + if let appServer = runtime?.appServer { + let retainedRestartIdentities = await appServer.discardAllPreparedReviewRestarts() + precondition( + retainedRestartIdentities.values.allSatisfy(\.isEmpty), + "Review workers must transfer every prepared-restart identity before runtime teardown." + ) + } + 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 { - if let task = runtimeSession?.stopTask { - _ = await task.value + 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 { - guard let appServerBackend else { - return settingsSnapshot - } - settingsSnapshot = try await Self.monitorSettings(from: appServerBackend.readSettings()) + let admitted = try requireAdmittedRuntimeBackend() + let refreshed = try await Self.monitorSettings(from: admitted.backend.readSettings()) + try requireRuntimeCommitAdmission(generation: admitted.generation) + settingsSnapshot = refreshed return settingsSnapshot } @@ -675,9 +1379,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 @@ -690,126 +1392,279 @@ 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() - try await applyAuthSnapshot(snapshot, to: auth) - } catch { - auth.updatePhase(.failed(.runtime(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 throws { - try await attachedStore?.requireReviewThreadRetentionAcceptance() try await beginStockLogin(auth: auth, request: .signIn) } func addAccount(auth: CodexReviewAuthModel) async throws { - try await attachedStore?.requireReviewThreadRetentionAcceptance() try await beginStockLogin(auth: auth, request: .addAccount) } - func cancelAuthentication(auth: CodexReviewAuthModel) async { + func cancelAuthentication(auth _: CodexReviewAuthModel) async { guard let session = loginSession else { - if auth.selectedAccount == nil { - auth.updatePhase(.signedOut) + if let activePrimaryAuthenticationReconciliation { + _ = await activePrimaryAuthenticationReconciliation.finalResult.wait() } return } - _ = await session.terminate(reason: .explicitCancellation) + 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() + } } func switchAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() - try await withAccountMutation { - let persisted = try await accountRegistry.activateAccount(accountKey) - applyAccountRegistrySnapshot(persisted, to: auth) - auth.updatePhase(.signedOut) + 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 } + await quiesceRuntimeAdmissionForAccountTransition() await attachedStore.closeActiveReviewSessions( reason: .system(message: "Account switched.") ) + 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) - await start(store: attachedStore, forceRestartIfNeeded: true) + 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 + ) } } func removeAccount(auth: CodexReviewAuthModel, accountKey: String) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() - try await withAccountMutation { - let before = try await accountRegistry.load() + 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 { - let prepared = try await accountRegistry.prepareIrreversibleRemoval( - accountKey: normalizedAccountKey - ) + 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 appServerBackend { - _ = try await appServerBackend.logout(.init(normalizedAccountKey)) + if let transitionBackend { + _ = try await transitionBackend.logout(.init(normalizedAccountKey)) } - persisted = try await accountRegistry.commitPreparedMutation(prepared) } catch { - try await abortPreparedAccountMutation(prepared, after: error) + 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 ) } await accountRegistry.cleanupRemovedAccountDirectory(accountKey: normalizedAccountKey) - applyAccountRegistrySnapshot(persisted, to: auth) if removedActiveAccount { - 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.") + try await finishCommittedAccountTransition( + expectedAccount: .signedOut, + persisted: persisted, + transition: transition, + auth: auth, + store: transitionStore ) - await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) - await start(store: attachedStore, forceRestartIfNeeded: true) + } else { + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + } } } } @@ -819,53 +1674,135 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { accountKey: String, toIndex: Int ) async throws { - try await withAccountMutation { + try await withAccountMutation { transition, _ in + guard case .apply = accountRuntimeTransitionCoordinator.claimEffect(transition) else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication + } let persisted = try await accountRegistry.reorderAccount( accountKey: accountKey, toIndex: toIndex ) - applyAccountRegistrySnapshot(persisted, to: auth) + accountRuntimeTransitionCoordinator.recordRegistryCommit(transition) + if case .published = accountRuntimeTransitionCoordinator.claimPublication(transition) { + applyAccountRegistrySnapshot(persisted, to: auth) + } } } func signOutActiveAccount(auth: CodexReviewAuthModel) async throws { try await attachedStore?.requireReviewThreadRetentionAcceptance() - try await withAccountMutation { - let before = try await accountRegistry.load() + try await withAccountMutation { transition, mutation in + let before = mutation.before guard let accountKey = before.activeAccountKey else { - auth.updatePhase(.signedOut) - auth.selectPersistedAccount(nil) return } - let shouldRecycleRuntime = attachedStore != nil && appServerBackend != nil - if shouldRecycleRuntime { - await attachedStore?.closeActiveReviewSessions( + 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 = try await accountRegistry.prepareIrreversibleRemoval( - accountKey: accountKey - ) - let persisted: AccountRegistryStore.Snapshot + 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 + } + 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 + } + var forwardRecoveredSnapshot: AccountRegistryStore.Snapshot? do { - if let appServerBackend { - _ = try await appServerBackend.logout(.init(accountKey)) + if let transitionBackend { + _ = try await transitionBackend.logout(.init(accountKey)) } - persisted = try await accountRegistry.commitPreparedMutation(prepared) } catch { - try await abortPreparedAccountMutation(prepared, after: error) + 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, + 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) - applyAccountRegistrySnapshot(persisted, to: auth) - auth.updatePhase(.signedOut) if shouldRecycleRuntime, let attachedStore { - await stop(store: attachedStore, purpose: .accountTransitionPreservingRuns) - await start(store: attachedStore, forceRestartIfNeeded: true) + 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 } @@ -877,27 +1814,187 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } private func withAccountMutation( - _ operation: () async throws -> T + _ operation: ( + AccountRuntimeTransitionCoordinator.AccountTransition, + AccountMutationContext + ) async throws -> T ) async throws -> T { - try await accountRuntimeTransitionCoordinator.perform { - let lease = try await accountRegistry.beginAccountMutation() + 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() - await accountRegistry.finishMutation(lease) + let result = try await operation(transition, mutation) + await accountRegistry.finishMutation(mutation.lease) return result } catch { - await accountRegistry.finishMutation(lease) + 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 abortPreparedAccountMutation( + 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 { + 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)" + ) + } + if accountRuntimeTransitionCoordinator.commitAccountReconciliationFailure(transition) { + store.transitionToFailed(message) + } + throw CodexReviewAuthenticationFailure.accountCommit(message: message) + } + } + + private func commitPreparedAccountMutation( _ mutation: AccountRegistryStore.PreparedMutation, - after originalError: any Error + 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) + } + 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 + ) + } 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 { - try await accountRegistry.abortPreparedMutation(mutation) + 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. " @@ -905,7 +2002,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { + "Recovery failure: \(error.localizedDescription)" ) } - throw originalError } private func beginStockLogin( @@ -915,10 +2011,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard loginSession == nil else { throw CodexReviewAuthenticationFailure.alreadyInProgress } - let authenticationMutation = try await accountRegistry.beginAuthenticationMutation( - request: request - ) + 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 + } + authenticationMutation = try await accountRegistry.beginAuthenticationMutation( + request: request + ) + } 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 = { @@ -934,6 +2051,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { let session = LoginSession( generationID: generationID, purpose: purpose, + previousActiveAccountKey: authenticationMutation.previousActiveAccountKey, mutationLease: mutationLease, cancellationTimeout: .seconds(5), rootOperation: { @MainActor [weak self, weak auth] operationState, startCompletion in @@ -950,8 +2068,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } catch { let failure = (error as? CodexReviewAuthenticationFailure) ?? .runtime(message: error.localizedDescription) - await startCompletion.resolve(.failure(failure)) - return finish(.failure(failure)) + 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(())) @@ -966,21 +2090,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } catch { let failure = (error as? CodexReviewAuthenticationFailure) ?? .runtime(message: error.localizedDescription) - await startCompletion.resolve(.failure(failure)) - return finish(.failure(failure)) + 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 ) - auth?.updatePhase(.signingIn(.init( - title: "Sign in to Codex", - detail: "Continue signing in with your browser.", - browserURL: handle.authenticationURL.absoluteString, - userCode: nil - ))) - if case .cancel = handleDisposition { await startCompletion.resolve(.success(())) do { @@ -994,23 +2117,48 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } + 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 await urlOpener(handle.authenticationURL) + try urlOpener(handle.authenticationURL) await startCompletion.resolve(.success(())) } catch { let failure = CodexReviewAuthenticationFailure.urlOpen(handle.authenticationURL) - await startCompletion.resolve(.failure(failure)) 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)) } } @@ -1038,12 +2186,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { // 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 { - _ = await session.terminate(reason: .rootOutcome) - if case .addAccountPreservingActive = purpose { - throw failure + let terminal = await session.terminate(reason: .rootOutcome) + if case .addAccountPreservingActive = purpose, + case .failed(let terminalFailure) = terminal { + throw terminalFailure } + _ = failure } } @@ -1085,10 +2241,138 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } - await closeLoginRuntimeIfNeeded(session) - await releaseLoginMutationIfNeeded(session) - clearLoginSessionIfCurrent(session) - return terminal + 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( @@ -1112,29 +2396,33 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth: auth ) case .authenticationCommittedNeedsConnectionReconciliation(let reconciliationReason): - if case .signIn = session.purpose, - sessionAllowsPrimaryReconciliation(terminationReason: terminationReason) - { - return await reconcilePrimaryAuthentication( - session: session, - reason: reconciliationReason, - auth: auth + 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 + ) + ) ) } - let failure = terminationFailure( - for: terminationReason, - fallback: .login( - message: "Authentication requires runtime reconciliation: \(String(describing: reconciliationReason))" + guard case .signIn = session.purpose else { + let failure = CodexReviewAuthenticationFailure.protocolViolation( + message: "An isolated add-account login cannot hand off primary authentication reconciliation." ) - ) - if let failure { auth.updatePhase(.failed(failure)) return .failed(failure) } - if auth.selectedAccount == nil { - auth.updatePhase(.signedOut) - } - return .stopped + return .primaryRuntimeReconciliation( + session.takePrimaryAuthenticationReconciliationHandoff( + cause: .committed(reconciliationReason) + ) + ) } } @@ -1150,51 +2438,215 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return .failed(failure) } var stagingURLRequiringRemoval: URL? - defer { - if let stagingURLRequiringRemoval { - try? FileManager.default.removeItem(at: stagingURLRequiringRemoval) - } - } + var deferredPrimaryExpectation: ExpectedRuntimeAccount = .anyChatGPT + var primaryObservationPublication: ( + session: HostRuntimeSession, + observation: RuntimeAccountObservation, + revision: UInt64 + )? do { - let snapshot = try await loginRuntime.backend.readAuth() - let isolatedRateLimits: CodexRateLimits? - if loginRuntime.usesPrimaryRuntime == false { - isolatedRateLimits = try? await loginRuntime.backend.readRateLimits() + 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 runtime.appServer.close() + await appServerCloser(runtime.appServer) stagingURLRequiringRemoval = runtime.codexHomeURL - } else { - isolatedRateLimits = nil - } - let account = try await applyAuthSnapshot( - snapshot, - to: auth, - activation: session.purpose.activation, - authSourceCodexHomeURL: loginRuntime.codexHomeURL - ) - if loginRuntime.usesPrimaryRuntime == false { - if let account, let isolatedRateLimits { - applyRateLimits(isolatedRateLimits, to: account) - try await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) + 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 ) } - } else { - await refreshSelectedAccountRateLimits(auth: auth) + applyAccountRegistrySnapshot(reconciliation.persisted, to: auth) + auth.updatePhase(.signedOut) + } + if let stagingURLRequiringRemoval { + await accountRegistry.finishTemporaryCodexHome(stagingURLRequiringRemoval) } return .succeeded - } catch let failure as CodexReviewAuthenticationFailure { - auth.updatePhase(.failed(failure)) - return .failed(failure) } catch { - let failure = CodexReviewAuthenticationFailure.login( - message: error.localizedDescription + 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, + 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." ) - auth.updatePhase(.failed(failure)) - return .failed(failure) } + 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 { + 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( @@ -1250,99 +2702,133 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func terminationFailure( - for reason: LoginTerminationReason, - fallback: CodexReviewAuthenticationFailure - ) -> CodexReviewAuthenticationFailure? { - switch reason { - case .rootOutcome: - return nil - case .explicitCancellation: - return fallback - case .urlOpenFailure(let failure), .runtimeFailure(let failure): - return failure - case .storeStop: - return nil - } - } - - private func sessionAllowsPrimaryReconciliation( - terminationReason: LoginTerminationReason - ) -> Bool { - switch terminationReason { - case .rootOutcome, .explicitCancellation, .urlOpenFailure: - return true - case .runtimeFailure, .storeStop: - return false - } - } - - private func reconcilePrimaryAuthentication( - session: LoginSession, - reason: CodexLoginReconciliationReason, - auth: CodexReviewAuthModel - ) async -> LoginSessionTerminal { - guard detachLoginSessionIfCurrent(session) else { - 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 { - try await accountRuntimeTransitionCoordinator.perform { - guard let store = attachedStore else { - throw CodexReviewAuthenticationFailure.runtime( - message: "Authentication committed, but the review store is unavailable for reconciliation." - ) - } + guard let store = attachedStore else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication committed, but the review store is unavailable for reconciliation." + ) + } + if oldRuntimeAlreadyStopped == false { await stop(store: store, purpose: .loginReconciliationPreservingRuns) - await start(store: store, forceRestartIfNeeded: true) - guard let backend = appServerBackend else { - throw CodexReviewAuthenticationFailure.runtime( - message: "Authentication committed, but the replacement runtime failed to start." - ) - } - let snapshot = try await backend.readAuth() - guard let activeAccountID = snapshot.activeAccountID?.rawValue, - let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), - backendAccount.kind == .chatGPT - else { - throw CodexReviewAuthenticationFailure.protocolViolation( - message: "Authentication committed, but the replacement runtime did not confirm a ChatGPT account." - ) - } - _ = try await applyAuthSnapshotSerialized(snapshot, to: auth) } - return .succeeded - } catch let failure as CodexReviewAuthenticationFailure { - auth.updatePhase(.failed(failure)) - logger.error( - "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(failure.localizedDescription, privacy: .public)" - ) - return .failed(failure) + 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 accountObservation = runtimeSession?.accountObservation else { + throw CodexReviewAuthenticationFailure.runtime( + message: "Authentication reconciliation completed without a validated account observation." + ) + } + 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." + ) + } } catch { - let failure = CodexReviewAuthenticationFailure.runtime(message: error.localizedDescription) - auth.updatePhase(.failed(failure)) + 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)" + ) + } + if accountRuntimeTransitionCoordinator.commitPrimaryReconciliationFailure(reservation) { + attachedStore?.transitionToFailed(message) + auth.updatePhase(.failed(.accountCommit(message: message))) + } logger.error( - "Primary authentication reconciliation failed after \(String(describing: reason), privacy: .public): \(error.localizedDescription, privacy: .public)" + "Primary authentication reconciliation deferred after \(String(describing: handoff.cause), privacy: .public): \(message, privacy: .public)" ) - return .failed(failure) + 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 clearLoginSessionIfCurrent(_ session: LoginSession) { - guard loginSession === session, - loginSession?.generationID == session.generationID else { - return + 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 + ) { + guard let activePrimaryAuthenticationReconciliation else { + preconditionFailure("A primary authentication reconciliation must resolve its active final-result slot.") } - loginSession = nil + precondition( + activePrimaryAuthenticationReconciliation.loginGenerationID == handoff.loginGenerationID + && activePrimaryAuthenticationReconciliation.finalResult === handoff.finalResult, + "Only the active primary authentication reconciliation can clear its final-result slot." + ) + self.activePrimaryAuthenticationReconciliation = nil } - @discardableResult - private func detachLoginSessionIfCurrent(_ session: LoginSession) -> Bool { + private func clearLoginSessionIfCurrent(_ session: LoginSession) { guard loginSession === session, loginSession?.generationID == session.generationID else { - return false + return } loginSession = nil - return true } private func closeLoginRuntime(_ runtime: LoginRuntime?) async { @@ -1385,9 +2871,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { usesPrimaryRuntime: true ) case .addAccountPreservingActive: - let temporaryCodexHomeURL = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-review-auth-\(UUID().uuidString)", isDirectory: true) - let runtime = try await appServerRuntimeFactory(temporaryCodexHomeURL) + 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, @@ -1405,6 +2898,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func startReview(_ request: CodexReviewBackendModel.Review.Start) async throws -> BackendReviewAttempt { + try requireOperationAdmission() guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } @@ -1419,6 +2913,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } func prepareReviewRestart(_ attempt: ReviewAttempt) async throws -> CodexReviewBackendModel.Review.RestartToken { + try requireOperationAdmission() guard let appServerBackend else { throw CodexReviewAPI.Error.io("Review runtime is not running.") } @@ -1429,6 +2924,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { _ 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.") } @@ -1489,23 +2985,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } - @discardableResult - private func applyAuthSnapshot( - _ snapshot: CodexReviewBackendModel.Auth.Snapshot, - to auth: CodexReviewAuthModel, - activation: LoginActivation = .activateAuthenticatedAccount, - authSourceCodexHomeURL: URL? = nil - ) async throws -> CodexReviewAccount? { - try await accountRuntimeTransitionCoordinator.performInternal { - try await self.applyAuthSnapshotSerialized( - snapshot, - to: auth, - activation: activation, - authSourceCodexHomeURL: authSourceCodexHomeURL - ) - } - } - private func applyAccountRegistrySnapshot( _ snapshot: AccountRegistryStore.Snapshot, to auth: CodexReviewAuthModel @@ -1517,30 +2996,44 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { auth.selectPersistedAccount(snapshot.activeAccountKey) } - @discardableResult - private func applyAuthSnapshotSerialized( + private func reconcileAuthSnapshotSerialized( _ snapshot: CodexReviewBackendModel.Auth.Snapshot, - to auth: CodexReviewAuthModel, activation: LoginActivation = .activateAuthenticatedAccount, - authSourceCodexHomeURL: URL? = nil - ) async throws -> CodexReviewAccount? { - guard let activeAccountID = snapshot.activeAccountID?.rawValue, - let backendAccount = snapshot.accounts.first(where: { $0.id.rawValue == activeAccountID }), - let account = Self.monitorAccount(from: backendAccount) - else { + 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() - auth.applyPersistedAccountStates( - persisted.accounts, - activeAccountKey: persisted.activeAccountKey + let persisted = try await accountRegistry.deactivateAccount( + authorization: authorization, + runtimeAuthorization: runtimeAuthorization + ) + return (persisted, nil) + } + 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." ) - auth.selectPersistedAccount(nil) - auth.updatePhase(.signedOut) - return nil + } + if let authenticatedRateLimits { + applyRateLimits(authenticatedRateLimits, to: account) } let accountPayload = savedAccountPayload(from: account) let persisted: AccountRegistryStore.Snapshot @@ -1548,21 +3041,20 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { persisted = try await accountRegistry.commitAuthenticatedAccount( accountPayload, activation: activation, - authSourceCodexHomeURL: authSourceCodexHomeURL + authSourceCodexHomeURL: authSourceCodexHomeURL, + authorization: authorization, + runtimeAuthorization: runtimeAuthorization, + isolatedProductCommitAuthorization: isolatedProductCommitAuthorization ) } else { persisted = try await accountRegistry.upsertAccount( accountPayload, - activation: activation + activation: activation, + authorization: authorization, + runtimeAuthorization: runtimeAuthorization ) } - auth.applyPersistedAccountStates( - persisted.accounts, - activeAccountKey: persisted.activeAccountKey - ) - auth.selectPersistedAccount(persisted.activeAccountKey) - auth.updatePhase(.signedOut) - return auth.persistedAccounts.first(where: { $0.accountKey == account.accountKey }) + return (persisted, account) } private func installRuntimeConsumers( @@ -1603,7 +3095,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { guard let self, let store else { return } - self.runtimeConsumerDidExit( + await self.runtimeConsumerDidExit( generation: generation, failure: failure, store: store @@ -1618,8 +3110,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { store: CodexReviewStore ) async { guard let session = runtimeSession, - session.generation == generation, - let backend = session.activeRuntime?.backend else { + session.generation == generation else { + return + } + if case .accountUpdated = event { + session.recordAccountInvalidation() + } + guard let backend = session.activeRuntime?.backend else { return } await handleAuthNotification( @@ -1634,7 +3131,7 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { generation: UInt64, failure: HostRuntimeConsumerFailure, store: CodexReviewStore - ) { + ) async { guard let session = runtimeSession, session.generation == generation else { return @@ -1643,21 +3140,31 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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.requestStop(purpose: .runtimeRestartPreservingRuns) { session in - let didReleaseResources = await self.performRuntimeStop( + 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)) ) - if didReleaseResources, self.runtimeSession === session { - self.runtimeSession = nil - } - return didReleaseResources } + guard let stopTask = requestedStopTask else { + return + } + _ = installRuntimeStopFollowup( + stopTask: stopTask, + session: session, + store: store + ) case .stopping, .stopIncomplete, .stopped: return } @@ -1671,19 +3178,16 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) async { switch event { case .accountUpdated: - if loginSession != nil { - return - } - await refreshAuthAfterAccountNotification( - generation: generation, - backend: backend, - store: store - ) + schedulePendingRuntimeAuthDrain() case .rateLimitsUpdated(let rateLimits): guard acceptsRuntimeEvent(generation: generation) else { return } - await applyRateLimitsUpdatedNotification(rateLimits, auth: store.auth) + 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: @@ -1691,40 +3195,165 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } - private func refreshAuthAfterAccountNotification( + private func refreshAuthAfterAccountNotification( + generation: UInt64, + backend: AppServerCodexReviewBackend, + 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, - store: CodexReviewStore - ) async { - do { - let snapshot = try await backend.readAuth() - guard acceptsRuntimeEvent(generation: generation) else { - return + 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 readRevision = session.accountInvalidationRevision + let snapshot: CodexReviewBackendModel.Auth.Snapshot do { - try await accountRuntimeTransitionCoordinator.perform { - guard self.acceptsRuntimeEvent(generation: generation) else { - return + 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)) } - try await self.applyAuthSnapshotSerialized(snapshot, to: store.auth) } - } catch CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication { - logger.debug("Dropping an account notification while an account transition owns publication") - return + return false } - guard acceptsRuntimeEvent(generation: generation) else { - return + + guard let currentSession = currentActiveRuntimeSession(generation: generation) else { + return false } - await refreshSelectedAccountRateLimits(auth: store.auth) - } catch { - guard acceptsRuntimeEvent(generation: generation) else { - return + 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)) + } + 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 { + 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 } - store.auth.updatePhase(.failed(.runtime(message: error.localizedDescription))) + 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 acceptsRuntimeEvent(generation: UInt64) -> Bool { + private func currentActiveRuntimeSession( + generation: UInt64 + ) -> HostRuntimeSession? { + guard let session = runtimeSession, + session.generation == generation, + session.isActive else { + return nil + } + return session + } + + private func isCurrentActiveRuntimeGeneration(_ generation: UInt64) -> Bool { guard let session = runtimeSession, session.generation == generation else { return false @@ -1732,8 +3361,49 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return session.isActive } + private func acceptsRuntimeEvent(generation: UInt64) -> Bool { + guard let session = runtimeSession, + session.generation == generation else { + return false + } + return session.isActive && accountRuntimeTransitionCoordinator.acceptsNewOperations + } + + private func requireOperationAdmission() throws { + guard acceptsNewReviewOperations else { + throw CodexReviewAPI.Error.io("The review runtime is changing accounts or stopping.") + } + } + + 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.") + } + return (session.generation, backend) + } + + 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 { @@ -1742,8 +3412,17 @@ 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) - _ = await persistRateLimitMetadata(for: selectedAccount) + _ = await persistRateLimitMetadata( + for: selectedAccount, + runtimeAuthorization: runtimeCommitAuthorization(generation: generation) + ) } private func refreshSelectedAccountRateLimits(auth: CodexReviewAuthModel) async { @@ -1761,23 +3440,115 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { await refreshSavedAccountRateLimits(for: account) return } - let didRefresh = await refreshRateLimits(for: account, using: appServerBackend, source: "active-runtime") - if didRefresh { - do { - try await persistRefreshedSharedAuth( - from: codexHomeURL, - for: account - ) - } catch { - recordRateLimitRefreshFailure(error, account: account) - _ = await persistRateLimitMetadata(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 await accountRegistry.copySavedAuth( accountKey: account.accountKey, @@ -1788,24 +3559,28 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { error: "Saved account authentication is not available." ) _ = 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 await accountRegistry.saveSharedAuth( - from: temporaryCodexHomeURL, - for: savedAccountPayload(from: account) - ) - } - } 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) + await closeIsolatedLoginRuntime( + appServer: isolatedAppServer, + codexHomeURL: temporaryCodexHomeURL + ) account.updateRateLimitFetchMetadata(fetchedAt: Date(), error: error.localizedDescription) _ = await persistRateLimitMetadata(for: account) } @@ -1814,13 +3589,13 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { 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 @@ -1870,14 +3645,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) + if let appServer { + await appServerCloser(appServer) + } + await accountRegistry.finishTemporaryCodexHome(codexHomeURL) } private func applyRateLimits( @@ -1899,25 +3678,38 @@ 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 await accountRegistry.saveSharedAuth( from: sourceCodexHomeURL, - for: savedAccountPayload(from: account) + for: savedAccountPayload(from: account), + runtimeAuthorization: runtimeAuthorization ) } @discardableResult - private func persistRateLimitMetadata(for account: CodexReviewAccount) async -> Bool { + private func persistRateLimitMetadata( + for account: CodexReviewAccount, + runtimeAuthorization: AccountRegistryStore.RuntimeCommitAuthorization? = nil + ) async -> Bool { do { try await accountRegistry.updateCachedRateLimits( - from: savedAccountPayload(from: account) + 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)") @@ -1977,6 +3769,88 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) } + 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." + ) + } + 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)." + ) + } + 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)." + ) + } + case .anyChatGPT: + guard activeAccount?.kind == .chatGPT else { + throw CodexReviewAuthenticationFailure.protocolViolation( + message: "The replacement runtime did not confirm the committed ChatGPT authentication." + ) + } + 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." + ) + } + case .reconcileCurrentRuntime: + break + } + } + + private func runtimeAccountObservation( + from snapshot: CodexReviewBackendModel.Auth.Snapshot + ) -> RuntimeAccountObservation { + guard let activeAccountID = snapshot.activeAccountID else { + return .signedOut + } + guard let backendAccount = snapshot.accounts.first(where: { $0.id == activeAccountID }), + let account = Self.monitorAccount(from: backendAccount) else { + return .invalid + } + return .account( + accountKey: account.accountKey, + provider: .init(account.kind) + ) + } + + private func shouldReconcileRuntimeAuthSnapshot( + expectation: ExpectedRuntimeAccount?, + observation: RuntimeAccountObservation + ) -> Bool { + guard case .cancelOutcomeUnknown(let previousActiveAccountKey) = expectation else { + return true + } + switch observation { + case .signedOut: + return previousActiveAccountKey != nil + case .account: + return true + case .invalid: + return true + } + } + } @MainActor @@ -1986,6 +3860,38 @@ private struct AppServerRuntime: Sendable { var backend: AppServerCodexReviewBackend } +private struct HostRuntimeStopResult: Sendable { + var didReleaseResources: Bool + var didRetireRuns: Bool + var primaryAuthenticationHandoff: PrimaryAuthenticationReconciliationHandoff? +} + +private 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 + } + } +} + +private struct RuntimeAccountObservationAuthorization: Equatable, Sendable { + let generation: UInt64 + let revision: UInt64 + let observation: RuntimeAccountObservation +} + @MainActor private final class HostRuntimeSession { enum Phase { @@ -2005,59 +3911,142 @@ private final class HostRuntimeSession { private(set) var accountConsumerTask: Task? private(set) var connectionEvents: CodexConnectionEvents? private(set) var connectionConsumerTask: Task? - private(set) var stopTask: 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? + lifecycleHandler: CodexReviewAppServerLifecycleHandler?, + finalRetirementDidClaim: CodexReviewFinalRuntimeRetirementDidClaim? ) { self.generation = generation self.lifecycleHandler = lifecycleHandler + self.finalRetirementDidClaim = finalRetirementDidClaim } var activeRuntime: AppServerRuntime? { - guard case .active = phase else { + guard case .active = phase, admissionOpen else { return nil } return runtime } - var activeMCPHTTPServer: (any CodexReviewMCPHTTPServing)? { - guard case .active = phase else { + 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 } - return mcpHTTPServer - } - - var isActive: Bool { - if case .active = phase { - return true + let expectedObservation = RuntimeAccountObservation.account( + accountKey: account.accountKey, + provider: .init(account.kind) + ) + guard accountObservation == expectedObservation else { + return nil } - return false - } - - 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 + return .init( + generation: generation, + revision: accountObservationRevision, + observation: expectedObservation + ) } - 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 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) -> Void + exitSink: @escaping @MainActor @Sendable (HostRuntimeConsumerFailure) async -> Void ) { precondition( self.accountEvents == nil && accountConsumerTask == nil @@ -2072,12 +4061,12 @@ private final class HostRuntimeSession { await accountEventSink(event) } if Task.isCancelled == false { - exitSink(.init(message: "The Codex account event stream ended unexpectedly.")) + 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)") - exitSink(.init(message: error.localizedDescription)) + await exitSink(.init(message: error.localizedDescription)) } } self.connectionEvents = connectionEvents @@ -2093,12 +4082,12 @@ private final class HostRuntimeSession { case .unknown: logger.debug("Unknown app-server notification") case .terminated(let termination): - exitSink(.init(message: Self.failureMessage(for: termination))) + await exitSink(.init(message: Self.failureMessage(for: termination))) return } } if Task.isCancelled == false { - exitSink(.init(message: "The Codex connection event stream ended unexpectedly.")) + await exitSink(.init(message: "The Codex connection event stream ended unexpectedly.")) } } } @@ -2109,6 +4098,7 @@ private final class HostRuntimeSession { preconditionFailure("A Host runtime session requires a model container before publication.") } phase = .active + admissionOpen = true didPublishLifecycle = true lifecycleHandler?(modelContainer) } @@ -2136,6 +4126,11 @@ private final class HostRuntimeSession { case .stopping, .stopped: return } + closeAdmission() + } + + func closeAdmission() { + admissionOpen = false if didPublishLifecycle { didPublishLifecycle = false lifecycleHandler?(nil) @@ -2155,13 +4150,40 @@ private final class HostRuntimeSession { accountConsumerTask = nil } - func waitForStopCompletion() async -> Bool? { + 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 { @@ -2171,6 +4193,8 @@ private final class HostRuntimeSession { accountConsumerTask = nil connectionEvents = nil connectionConsumerTask = nil + accountObservation = nil + accountObservationRevision = nil phase = .stopped } else { phase = .stopIncomplete @@ -2179,10 +4203,14 @@ private final class HostRuntimeSession { func requestStop( purpose: CodexReviewRuntimeStopPurpose, - _ operation: @escaping @MainActor @Sendable (HostRuntimeSession) async -> Bool - ) -> Task? { - if purpose.retiresRuns { + _ 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 @@ -2196,14 +4224,19 @@ private final class HostRuntimeSession { break } beginStopping() - let task = Task { @MainActor [weak self] in + let task: Task = Task { @MainActor [weak self] in guard let self else { - return true + return .init( + didReleaseResources: true, + didRetireRuns: false, + primaryAuthenticationHandoff: nil + ) } - let didReleaseResources = await operation(self) + await self.finalRetirementClaimTask?.value + let result = await operation(self) self.stopTask = nil - self.finishStopping(didReleaseResources: didReleaseResources) - return didReleaseResources + self.finishStopping(didReleaseResources: result.didReleaseResources) + return result } stopTask = task return task @@ -2244,86 +4277,208 @@ actor AccountRegistryStore { fileprivate let id: UUID } + struct AccountMutation: Sendable { + let lease: MutationLease + let before: Snapshot + } + struct PreparedMutation: Hashable, Sendable { fileprivate let id: UUID } + enum PreparedAbortDisposition: Sendable { + case restoredBefore(Snapshot) + case forwardedDesired(Snapshot) + } + + struct RuntimeCommitAuthorization: Sendable { + fileprivate 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 + + fileprivate var pathPrefix: String { + switch self { + case .authentication: + "codex-review-auth-" + case .rateLimits: + "codex-review-rate-limits-" + } + } } - private enum MutationKind { + private enum MutationKind: Equatable { case authentication case account } let codexHomeURL: URL - private var activeMutation: (lease: MutationLease, kind: MutationKind)? + 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) { + 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() throws -> Snapshot { - try Disk.load(codexHomeURL: codexHomeURL) + func load() async throws -> Snapshot { + await loadDidBegin?() + return try Disk.load(codexHomeURL: codexHomeURL) } - func deactivateAccount() throws -> Snapshot { - try Disk.deactivateAccount(codexHomeURL: codexHomeURL) - 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 activateAccount(_ accountKey: String) throws -> Snapshot { - try Disk.activateAccount(accountKey, codexHomeURL: codexHomeURL) - return try Disk.load(codexHomeURL: codexHomeURL) + 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) throws { + func updateCachedRateLimits( + from account: CodexSavedAccountPayload, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) async throws { try requireNoAccountMutationForBackgroundPersistence() - try Disk.updateCachedRateLimits(from: account, codexHomeURL: codexHomeURL) + try requireRuntimeAuthorization(runtimeAuthorization) + try Disk.updateCachedRateLimits( + from: account, + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace + ) } func saveSharedAuth( from sourceCodexHomeURL: URL? = nil, - for account: CodexSavedAccountPayload + for account: CodexSavedAccountPayload, + runtimeAuthorization: RuntimeCommitAuthorization? = nil ) throws { try requireNoAccountMutationForBackgroundPersistence() + try requireRuntimeAuthorization(runtimeAuthorization) try Disk.saveSharedAuth( from: sourceCodexHomeURL ?? codexHomeURL, for: account, - codexHomeURL: codexHomeURL + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace ) } func commitAuthenticatedAccount( _ authenticatedAccount: CodexSavedAccountPayload, activation: LoginActivation, - authSourceCodexHomeURL: URL? - ) throws -> Snapshot { - try Disk.commitAuthenticatedAccount( - authenticatedAccount, - activation: activation, - authSourceCodexHomeURL: authSourceCodexHomeURL ?? codexHomeURL, - codexHomeURL: codexHomeURL - ) - return try Disk.load(codexHomeURL: codexHomeURL) + 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 - ) throws -> Snapshot { + activation: LoginActivation, + authorization: MutationLease?, + runtimeAuthorization: RuntimeCommitAuthorization? = nil + ) async throws -> Snapshot { + try requireMutationAuthorization(authorization) + try requireRuntimeAuthorization(runtimeAuthorization) try Disk.upsertAccount( account, activation: activation, - codexHomeURL: codexHomeURL + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace ) - return try Disk.load(codexHomeURL: codexHomeURL) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) } func prepareIrreversibleRemoval( @@ -2337,25 +4492,32 @@ actor AccountRegistryStore { func commitPreparedMutation(_ mutation: PreparedMutation) throws -> Snapshot { try Disk.commitPreparedMutation(mutation, codexHomeURL: codexHomeURL) - return try Disk.load(codexHomeURL: codexHomeURL) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) } - func abortPreparedMutation(_ mutation: PreparedMutation) throws { + 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) - return try Disk.load(codexHomeURL: codexHomeURL) + 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 + codexHomeURL: codexHomeURL, + destinationDidReplace: registryDestinationDidReplace ) - return try Disk.load(codexHomeURL: codexHomeURL) + return try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) } func cleanupRemovedAccountDirectory(accountKey: String) { @@ -2374,6 +4536,44 @@ actor AccountRegistryStore { } } + 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( @@ -2383,7 +4583,7 @@ actor AccountRegistryStore { ) } - func beginAuthenticationMutation(request: LoginRequest) throws -> AuthenticationMutation { + func beginAuthenticationMutation(request: LoginRequest) async throws -> AuthenticationMutation { if let activeMutation { switch activeMutation.kind { case .authentication: @@ -2392,24 +4592,36 @@ actor AccountRegistryStore { throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } } - let snapshot = try Disk.load(codexHomeURL: codexHomeURL) + let snapshot = try Disk.loadSnapshotWithoutMaintenance(codexHomeURL: codexHomeURL) let purpose: LoginPurpose = switch request { case .signIn: .signIn case .addAccount: snapshot.activeAccountKey == nil ? .signIn : .addAccountPreservingActive } - return .init( + let mutation = AuthenticationMutation( lease: installMutation(kind: .authentication), - purpose: purpose + purpose: purpose, + previousActiveAccountKey: snapshot.activeAccountKey ) + await authenticationMutationDidBegin?() + return mutation } - func beginAccountMutation() throws -> MutationLease { + func beginAccountMutation() async throws -> AccountMutation { guard activeMutation == nil else { throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } - return installMutation(kind: .account) + 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) { @@ -2417,12 +4629,40 @@ actor AccountRegistryStore { 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, kind) + 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( @@ -2430,41 +4670,47 @@ actor AccountRegistryStore { ) } } -} - -@MainActor -private final class AccountRuntimeTransitionCoordinator { - private var isTransitioning = false - private var transitionCompletionWaiters: [CheckedContinuation] = [] - func perform(_ operation: () async throws -> T) async throws -> T { - guard isTransitioning == false else { + 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 } - isTransitioning = true - defer { finishTransition() } - return try await operation() } - func performInternal(_ operation: () async throws -> T) async rethrows -> T { - while isTransitioning { - await withCheckedContinuation { continuation in - transitionCompletionWaiters.append(continuation) - } + private func requireRuntimeAuthorization( + _ authorization: RuntimeCommitAuthorization? + ) throws { + guard let authorization else { + return + } + guard activeRuntimeGeneration == authorization.generation else { + throw CodexReviewAuthenticationFailure.accountMutationBlockedByAuthentication } - isTransitioning = true - defer { finishTransition() } - return try await operation() } +} - private func finishTransition() { - precondition(isTransitioning) - isTransitioning = false - let waiters = transitionCompletionWaiters - transitionCompletionWaiters.removeAll(keepingCapacity: true) - for waiter in waiters { - waiter.resume() +private 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) + ) } } @@ -2472,6 +4718,8 @@ private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async thr private extension AccountRegistryStore { enum Disk { + private static let filesystemRootURL = URL(fileURLWithPath: "/", isDirectory: true) + private struct Registry: Codable { static let currentSchemaVersion = 1 @@ -2631,36 +4879,274 @@ enum Disk { } } - private struct MutationJournal: Codable { - enum Phase: String, Codable { - case prepared - case sharedAuthApplied - case registryCommitted - } + 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) + } - enum SharedAuthAction: String, Codable { - case replace - case remove + 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) + } - 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 + static func loadSnapshotWithoutMaintenance( + codexHomeURL: URL + ) throws -> AccountRegistryStore.Snapshot { + snapshot(from: try loadRegistry(codexHomeURL: codexHomeURL)) } - static func load(codexHomeURL: URL) throws -> AccountRegistryStore.Snapshot { - let registry = try loadRegistry(codexHomeURL: codexHomeURL) - try garbageCollectOrphanedRevisions( - referencedBy: registry, - 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) @@ -2671,13 +5157,20 @@ enum Disk { return .init(accounts: accounts, activeAccountKey: activeAccountKey) } - static func deactivateAccount(codexHomeURL: URL) throws { + 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) + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } private static func mergedEntries( @@ -2719,10 +5212,10 @@ enum Disk { } } - static func activateAccount( + static func prepareAccountActivation( _ accountKey: String, codexHomeURL: URL - ) throws { + ) throws -> AccountRegistryStore.PreparedMutation { let targetAccountKey = CodexReviewAccount.normalizedEmail(accountKey) let beforeRegistry = try loadRegistry(codexHomeURL: codexHomeURL) guard let entry = beforeRegistry.accounts.first(where: { @@ -2744,8 +5237,9 @@ enum Disk { desiredRegistry.accounts[index].lastActivatedAt = Date() } desiredRegistry = try nextRegistry(from: desiredRegistry) - var journal = MutationJournal( - id: UUID(), + let id = UUID() + let journal = MutationJournal( + id: id, phase: .prepared, beforeRegistry: beforeRegistry, desiredRegistry: desiredRegistry, @@ -2757,13 +5251,7 @@ enum Disk { mayApplyIrreversibleLogout: false ) try writeJournal(journal, codexHomeURL: codexHomeURL) - try copyAuth(from: savedAuthURL, to: sharedAuthURL(codexHomeURL: codexHomeURL)) - journal.phase = .sharedAuthApplied - try writeJournal(journal, codexHomeURL: codexHomeURL) - try persistRegistry(desiredRegistry, codexHomeURL: codexHomeURL) - journal.phase = .registryCommitted - try writeJournal(journal, codexHomeURL: codexHomeURL) - try removeJournal(codexHomeURL: codexHomeURL) + return .init(id: id) } static func prepareIrreversibleRemoval( @@ -2816,6 +5304,40 @@ enum Disk { 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) @@ -2828,7 +5350,7 @@ enum Disk { static func abortPreparedMutation( _ mutation: AccountRegistryStore.PreparedMutation, codexHomeURL: URL - ) throws { + ) throws -> AccountRegistryStore.PreparedAbortDisposition { let journal = try loadJournal(codexHomeURL: codexHomeURL) guard journal.id == mutation.id else { throw CodexReviewAuthenticationFailure.persistenceInconsistent( @@ -2840,14 +5362,25 @@ enum Disk { if sameRegistry(registry, journal.beforeRegistry), sharedFingerprint == journal.beforeSharedAuthFingerprint { try removeJournal(codexHomeURL: codexHomeURL) - return + 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 + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { var registry = try loadRegistry(codexHomeURL: codexHomeURL) guard let index = registry.accounts.firstIndex(where: { @@ -2865,7 +5398,11 @@ enum Disk { } registry.accounts[index].lastRateLimitFetchAt = account.lastRateLimitFetchAt registry.accounts[index].lastRateLimitError = account.lastRateLimitError - try saveRegistry(registry, codexHomeURL: codexHomeURL) + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } static func saveSharedAuth( @@ -2883,7 +5420,8 @@ enum Disk { _ authenticatedAccount: CodexSavedAccountPayload, activation: LoginActivation, authSourceCodexHomeURL: URL, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { let sourceData = try validatedAuthData( at: sharedAuthURL(codexHomeURL: authSourceCodexHomeURL) @@ -2894,7 +5432,6 @@ enum Disk { }) let sourceFingerprint = fingerprint(sourceData) let revision: String - let createdRevision: String? if let existingURL = existingEntry.flatMap({ entry in immutableAuthURL( for: entry, @@ -2905,14 +5442,12 @@ enum Disk { fingerprint(try validatedAuthData(at: existingURL)) == sourceFingerprint, let immutableRevision = existingEntry?.immutableRevision { revision = immutableRevision - createdRevision = nil } else { revision = try writeImmutableRevision( sourceData, accountKey: authenticatedAccount.accountKey, codexHomeURL: codexHomeURL ) - createdRevision = revision } var authenticatedAccount = authenticatedAccount if let existingPayload = existingEntry.flatMap(makePayload(from:)) { @@ -2947,26 +5482,19 @@ enum Disk { ) } desired.accounts[authenticatedIndex].immutableRevision = revision - do { - try saveRegistry(desired, codexHomeURL: codexHomeURL) - } catch { - if let createdRevision { - try? FileManager.default.removeItem( - at: immutableAuthURL( - accountKey: authenticatedAccount.accountKey, - revision: createdRevision, - codexHomeURL: codexHomeURL - ) - ) - } - throw error - } + let persistedDesired = try nextRegistry(from: desired) + try persistRegistry( + persistedDesired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } static func upsertAccount( _ account: CodexSavedAccountPayload, activation: LoginActivation, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { let existing = try loadRegistry(codexHomeURL: codexHomeURL) var account = account @@ -3001,13 +5529,15 @@ enum Disk { existing: existing.accounts ) ), - codexHomeURL: codexHomeURL + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace ) } static func removeInactiveAccount( accountKey: String, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) let existing = try loadRegistry(codexHomeURL: codexHomeURL) @@ -3019,13 +5549,18 @@ enum Disk { desired.accounts.removeAll { self.normalizedAccountKey(from: $0) == normalizedAccountKey } - try saveRegistry(desired, codexHomeURL: codexHomeURL) + try saveRegistry( + desired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } static func reorderAccount( accountKey: String, toIndex: Int, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { let normalizedAccountKey = CodexReviewAccount.normalizedEmail(accountKey) var registry = try loadRegistry(codexHomeURL: codexHomeURL) @@ -3040,21 +5575,27 @@ enum Disk { } let entry = registry.accounts.remove(at: sourceIndex) registry.accounts.insert(entry, at: destinationIndex) - try saveRegistry(registry, codexHomeURL: codexHomeURL) + try saveRegistry( + registry, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } static func saveSharedAuth( from sourceCodexHomeURL: URL, for account: CodexSavedAccountPayload, - codexHomeURL: URL + 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) - var registry = try loadRegistry(codexHomeURL: codexHomeURL) - guard let index = registry.accounts.firstIndex(where: { + 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( @@ -3062,7 +5603,7 @@ enum Disk { ) } if let existingURL = immutableAuthURL( - for: registry.accounts[index], + for: desiredRegistry.accounts[index], accountKey: account.accountKey, codexHomeURL: codexHomeURL ), FileManager.default.fileExists(atPath: existingURL.path) { @@ -3076,19 +5617,13 @@ enum Disk { accountKey: account.accountKey, codexHomeURL: codexHomeURL ) - registry.accounts[index].immutableRevision = revision - do { - try saveRegistry(registry, codexHomeURL: codexHomeURL) - } catch { - try? FileManager.default.removeItem( - at: immutableAuthURL( - accountKey: account.accountKey, - revision: revision, - codexHomeURL: codexHomeURL - ) - ) - throw error - } + desiredRegistry.accounts[index].immutableRevision = revision + let persistedDesired = try nextRegistry(from: desiredRegistry) + try persistRegistry( + persistedDesired, + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace + ) } static func removeSharedAuth(codexHomeURL: URL) throws { @@ -3205,11 +5740,13 @@ enum Disk { private static func saveRegistry( _ registry: Registry, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { try persistRegistry( nextRegistry(from: registry), - codexHomeURL: codexHomeURL + codexHomeURL: codexHomeURL, + destinationDidReplace: destinationDidReplace ) } @@ -3223,13 +5760,10 @@ enum Disk { private static func persistRegistry( _ registry: Registry, - codexHomeURL: URL + codexHomeURL: URL, + destinationDidReplace: CodexReviewRegistryDestinationDidReplace? = nil ) throws { let url = registryURL(codexHomeURL: codexHomeURL) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) guard registry.schemaVersion == Registry.currentSchemaVersion, registry.contentHash == (try contentHash(for: registry)) else { throw CodexReviewAuthenticationFailure.persistenceInconsistent( @@ -3238,11 +5772,41 @@ enum Disk { } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try writeAtomically( - encoder.encode(registry), - to: url, - permissions: 0o600 - ) + 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( @@ -3412,10 +5976,6 @@ enum Disk { } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - try FileManager.default.createDirectory( - at: journalURL(codexHomeURL: codexHomeURL).deletingLastPathComponent(), - withIntermediateDirectories: true - ) try writeAtomically( encoder.encode(journal), to: journalURL(codexHomeURL: codexHomeURL), @@ -3438,11 +5998,7 @@ enum Disk { private static func removeJournal(codexHomeURL: URL) throws { let url = journalURL(codexHomeURL: codexHomeURL) - guard FileManager.default.fileExists(atPath: url.path) else { - return - } - try FileManager.default.removeItem(at: url) - try synchronizeDirectory(at: url.deletingLastPathComponent()) + try removeDurably(at: url) } private static func recoverJournal( @@ -3533,36 +6089,16 @@ enum Disk { private static func copyAuth(from sourceURL: URL, to destinationURL: URL) throws { let sourceData = try validatedAuthData(at: sourceURL) - let destinationDirectoryURL = destinationURL.deletingLastPathComponent() - try FileManager.default.createDirectory( - at: destinationDirectoryURL, - withIntermediateDirectories: true + try writeAtomically( + sourceData, + to: destinationURL, + permissions: 0o600 ) - 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) - } - try synchronizeFile(at: destinationURL) - try synchronizeDirectory(at: destinationDirectoryURL) - let destinationData = try validatedAuthData(at: destinationURL) - guard fingerprint(destinationData) == fingerprint(sourceData) else { - throw CodexReviewAuthenticationFailure.accountCommit( - message: "Authentication copy fingerprint mismatch." - ) - } - } catch { - try? FileManager.default.removeItem(at: replacementURL) - throw error + let destinationData = try validatedAuthData(at: destinationURL) + guard fingerprint(destinationData) == fingerprint(sourceData) else { + throw CodexReviewAuthenticationFailure.accountCommit( + message: "Authentication copy fingerprint mismatch." + ) } } @@ -3580,16 +6116,8 @@ enum Disk { codexHomeURL: codexHomeURL ) let directoryURL = url.deletingLastPathComponent() - let accountDirectoryURL = directoryURL.deletingLastPathComponent() - let accountsURL = accountDirectoryURL.deletingLastPathComponent() - let codexHomeParentURL = codexHomeURL.deletingLastPathComponent() - let directoryExisted = FileManager.default.fileExists(atPath: directoryURL.path) - let accountDirectoryExisted = FileManager.default.fileExists(atPath: accountDirectoryURL.path) - let accountsDirectoryExisted = FileManager.default.fileExists(atPath: accountsURL.path) - let codexHomeExisted = FileManager.default.fileExists(atPath: codexHomeURL.path) - try FileManager.default.createDirectory( - at: directoryURL, - withIntermediateDirectories: true + try createDirectoryHierarchy( + at: directoryURL ) if FileManager.default.fileExists(atPath: url.path) { let existing = try validatedAuthData(at: url) @@ -3598,6 +6126,8 @@ enum Disk { message: "Immutable authentication revision \(revision) has conflicting content." ) } + try synchronizeFile(at: url) + try synchronizeDirectory(at: directoryURL) return revision } guard FileManager.default.createFile( @@ -3615,18 +6145,6 @@ enum Disk { try handle.synchronize() try handle.close() try synchronizeDirectory(at: directoryURL) - if directoryExisted == false { - try synchronizeDirectory(at: accountDirectoryURL) - } - if accountDirectoryExisted == false { - try synchronizeDirectory(at: accountsURL) - } - if accountsDirectoryExisted == false { - try synchronizeDirectory(at: codexHomeURL) - } - if codexHomeExisted == false { - try synchronizeDirectory(at: codexHomeParentURL) - } let persisted = try validatedAuthData(at: url) guard fingerprint(persisted) == fingerprint(data) else { throw CodexReviewAuthenticationFailure.accountCommit( @@ -3635,8 +6153,17 @@ enum Disk { } return revision } catch { - try? FileManager.default.removeItem(at: url) - throw error + 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 } } @@ -3666,9 +6193,15 @@ enum Disk { private static func writeAtomically( _ data: Data, to destinationURL: URL, - permissions: Int + 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)" ) @@ -3681,33 +6214,134 @@ enum Disk { 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() - if FileManager.default.fileExists(atPath: destinationURL.path) { - _ = try FileManager.default.replaceItemAt( - destinationURL, - withItemAt: replacementURL - ) - } else { - try FileManager.default.moveItem(at: replacementURL, to: destinationURL) - } + try renameAtomically(from: replacementURL, to: destinationURL) + didReplaceDestination = true + try destinationDidReplace?() try synchronizeFile(at: destinationURL) try synchronizeDirectory(at: directoryURL) } catch { - try? FileManager.default.removeItem(at: replacementURL) + 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 { @@ -3738,6 +6372,16 @@ enum Disk { .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") } diff --git a/Sources/CodexReviewHost/LoginSession.swift b/Sources/CodexReviewHost/LoginSession.swift index 767b618..a5b23f1 100644 --- a/Sources/CodexReviewHost/LoginSession.swift +++ b/Sources/CodexReviewHost/LoginSession.swift @@ -57,11 +57,60 @@ enum LoginTerminationReason: Equatable, Sendable { } } -enum LoginSessionTerminal: Equatable, Sendable { +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 { @@ -96,6 +145,11 @@ actor LoginOperationState { case cancel } + enum PreCommitFailureDisposition: Sendable { + case fail + case cancel + } + private enum Phase { case acquiringRuntime case runtimeBound(LoginRuntime) @@ -106,9 +160,16 @@ actor LoginOperationState { private var phase: Phase = .acquiringRuntime private var cancellationRequested = false private var cancellationClaimed = false + private var preCommitFailureClaimed = false + private var urlPresentationClaimed = false func requestCancellation() -> CodexLoginHandle? { - cancellationRequested = true + if preCommitFailureClaimed == false { + cancellationRequested = true + } + guard preCommitFailureClaimed == false else { + return nil + } guard cancellationClaimed == false else { return nil } @@ -119,6 +180,24 @@ actor LoginOperationState { 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.") @@ -140,6 +219,19 @@ actor LoginOperationState { 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: @@ -194,6 +286,7 @@ final class LoginSession { let generationID: UUID let purpose: LoginPurpose + let previousActiveAccountKey: String? private let mutationLease: AccountRegistryStore.MutationLease private let operationState = LoginOperationState() private let startCompletion = LoginStartCompletion() @@ -203,10 +296,13 @@ final class LoginSession { 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, @@ -214,6 +310,7 @@ final class LoginSession { ) { self.generationID = generationID self.purpose = purpose + self.previousActiveAccountKey = previousActiveAccountKey self.mutationLease = mutationLease self.cancellationTimeout = cancellationTimeout self.rootOperation = rootOperation @@ -248,6 +345,9 @@ final class LoginSession { 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 @@ -274,6 +374,77 @@ final class LoginSession { 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 { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStore.swift b/Sources/CodexReviewKit/Store/CodexReviewStore.swift index 1c4e12e..d348fcf 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStore.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStore.swift @@ -118,6 +118,16 @@ public final class CodexReviewStore { } 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 @@ -131,7 +141,14 @@ 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() } @@ -168,8 +185,17 @@ public final class CodexReviewStore { } package func restart() async { + guard let admission = backend.beginRuntimeRestart() else { + return + } await stop(purpose: .runtimeRestartPreservingRuns) - await start(forceRestartIfNeeded: true) + guard backend.claimRuntimeRestart(admission) else { + return + } + await start( + forceRestartIfNeeded: true, + restartAdmission: admission + ) } package func waitUntilStopped() async { diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift index f10b969..fff0814 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreBackend.swift @@ -27,11 +27,18 @@ 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, purpose: CodexReviewRuntimeStopPurpose) async func waitUntilStopped() async @@ -63,6 +70,14 @@ package protocol CodexReviewStoreBackend: CodexReviewSettingsBackend { ) 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 @@ -108,6 +123,25 @@ 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 } diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift index dca81e1..f71f595 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreReviews.swift @@ -70,7 +70,13 @@ extension CodexReviewStore { sessionID: String, request: CodexReviewAPI.Start.Request ) 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.") } diff --git a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift index 4b94a5f..09090f1 100644 --- a/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewKit/Store/PreviewCodexReviewStoreBackend.swift @@ -93,11 +93,7 @@ package class PreviewCodexReviewStoreBackend: CodexReviewStoreBackend { 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 { diff --git a/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift index 3ed5994..eb9c7fa 100644 --- a/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift +++ b/Sources/ReviewUIPreviewSupport/PreviewRuntimeLifetime.swift @@ -444,7 +444,17 @@ final class PreviewRuntimeLifetime: CodexReviewPreviewRuntimeLifetime { guard Task.isCancelled == false else { break } - await eventSink.emit(work.notification, using: runtime) + 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) } diff --git a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift index 902fc41..9e1a68f 100644 --- a/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift +++ b/Sources/ReviewUIPreviewSupport/ReviewMonitorPreviewAppServerRuntime.swift @@ -297,28 +297,24 @@ final class ReviewMonitorPreviewRuntimeEventSink { func emit( _ notification: ReviewMonitorPreviewRuntimeNotification, using runtime: CodexAppServerTestRuntime - ) async { + ) async throws { switch notification { case .itemLifecycle(let storedItem, let fixture): - await emitItemLifecycle(storedItem, for: fixture, using: runtime) + try await emitItemLifecycle(storedItem, for: fixture, using: runtime) case .textDelta(let delta, let itemID, let turnID, let chatID, let kind, let content): - do { - try await emitTextDelta( - delta, - itemID: itemID, - turnID: turnID, - chatID: chatID, - kind: kind, - content: content, - runtime: runtime - ) - } catch { - preconditionFailure("Failed to append Preview text: \(error)") - } + try await emitTextDelta( + delta, + itemID: itemID, + turnID: turnID, + chatID: chatID, + kind: kind, + content: content, + runtime: runtime + ) case .stream(let step, let storedItem, let fixture): - await emit(step, storedItem: storedItem, for: fixture, using: runtime) + try await emit(step, storedItem: storedItem, for: fixture, using: runtime) case .cancelled(let storedThread, let fixture): - await emitCancelledState(storedThread, for: fixture, using: runtime) + try await emitCancelledState(storedThread, for: fixture, using: runtime) } } @@ -327,42 +323,38 @@ final class ReviewMonitorPreviewRuntimeEventSink { storedItem: ReviewMonitorPreviewStoredThreadItem, for fixture: ReviewMonitorPreviewChatLogFixture, using runtime: CodexAppServerTestRuntime - ) async { - do { + ) 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, - chatID: fixture.chatID, - kind: storedItem.item.kind, - content: storedItem.item.content, - runtime: runtime + item: fixtureItem ) - case .update, .complete: - guard let fixtureItem = storedItem.fixtureItem else { - preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") - } - switch step.mode { - 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, - item: fixtureItem - ) - case .textDelta: - preconditionFailure("Text deltas are handled above.") - } + case .complete: + try await runtime.notificationEmitter.emitItemCompleted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) + case .textDelta: + preconditionFailure("Text deltas are handled above.") } - } catch { - preconditionFailure("Failed to emit a Preview stream item: \(error)") } } @@ -486,26 +478,22 @@ final class ReviewMonitorPreviewRuntimeEventSink { _ storedItem: ReviewMonitorPreviewStoredThreadItem, for fixture: ReviewMonitorPreviewChatLogFixture, using runtime: CodexAppServerTestRuntime - ) async { + ) async throws { guard let fixtureItem = storedItem.fixtureItem else { preconditionFailure("A preview item lifecycle event requires its canonical fixture item.") } - do { - if storedItem.item.isTerminalPreviewItem { - try await runtime.notificationEmitter.emitItemCompleted( - threadID: fixture.chatID, - turnID: storedItem.turnID, - item: fixtureItem - ) - } else { - try await runtime.notificationEmitter.emitItemStarted( - threadID: fixture.chatID, - turnID: storedItem.turnID, - item: fixtureItem - ) - } - } catch { - preconditionFailure("Failed to emit preview item lifecycle: \(error)") + if storedItem.item.isTerminalPreviewItem { + try await runtime.notificationEmitter.emitItemCompleted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) + } else { + try await runtime.notificationEmitter.emitItemStarted( + threadID: fixture.chatID, + turnID: storedItem.turnID, + item: fixtureItem + ) } } @@ -615,23 +603,19 @@ final class ReviewMonitorPreviewRuntimeEventSink { _ storedThread: CodexAppServerTestStoredThread, for fixture: ReviewMonitorPreviewChatLogFixture, using runtime: CodexAppServerTestRuntime - ) async { - do { - try await runtime.notificationEmitter.emitThreadStatusChanged( - threadID: fixture.chatID, - status: .idle - ) - 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 - } catch { - preconditionFailure("Failed to emit a cancelled Preview turn: \(error)") + ) async throws { + try await runtime.notificationEmitter.emitThreadStatusChanged( + threadID: fixture.chatID, + status: .idle + ) + 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 } } diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index e8485c8..92edba1 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -5,7 +5,7 @@ import CodexAppServerKit import CodexAppServerKitTesting import CodexReviewKit import CodexReviewAppServer -import CodexReviewHost +@testable import CodexReviewHost import CodexReviewMCPServer import CodexReviewTesting @@ -23,6 +23,16 @@ private extension CodexReviewStore { 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( @@ -34,6 +44,16 @@ private extension CodexReviewStore { 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, @@ -62,6 +82,16 @@ private extension CodexReviewStore { 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( @@ -74,6 +104,16 @@ private extension CodexReviewStore { 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( @@ -91,6 +131,30 @@ private extension CodexReviewStore { @Suite("host composition") @MainActor struct CodexReviewHostTests { + @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() + } + + try #require(await waitUntil(timeout: .seconds(2)) { + await reconciliationCompleted.isCompleted() + }) + #expect(coordinator.hasActiveLoginTransition) + + coordinator.finishLoginAdmission(try #require(admittedLogin)) + await reconciliation.value + } + @Test func runtimePreferencesNormalizeInvalidValues() { let preferences = CodexReviewRuntime.Preferences( codexHomePath: " ", @@ -492,6 +556,133 @@ struct CodexReviewHostTests { #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.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 + ) + + 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() @@ -737,6 +928,117 @@ struct CodexReviewHostTests { 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" @@ -774,6 +1076,102 @@ struct CodexReviewHostTests { #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 @@ -948,13 +1346,13 @@ struct CodexReviewHostTests { try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) try await transport.enqueueModels(.init(models: [])) try await transport.enqueueChatGPTLogin(loginID: "login-1", authenticationURL: testAuthenticationURL) - try await transport.enqueueAccount( - try CodexAppServerTestAccount(kind: .chatGPT( - email: "new@example.com", - planType: .plus - )), - requiresOpenAIAuth: false - ) + 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, @@ -1003,7 +1401,7 @@ struct CodexReviewHostTests { #expect(await waitUntil(timeout: .seconds(1)) { store.auth.selectedAccount?.accountKey == "new@example.com" }) - await transport.waitForRequestCount(7) + await transport.waitForRequestCount(9) #expect(await transport.recordedRequests().map(\.request.operation) == [ .initialize, .accountRead, @@ -1011,7 +1409,9 @@ struct CodexReviewHostTests { .modelList, .accountLoginStart, .accountRead, + .accountRead, .accountRateLimitsRead, + .accountRead, ]) await store.stop() } @@ -1052,56 +1452,2006 @@ struct CodexReviewHostTests { await store.stop() } - @Test func liveStoreJoinsConcurrentCancellationAndStopInOneLoginTermination() async throws { + @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-1", authenticationURL: testAuthenticationURL) - try await transport.enqueueChatGPTLoginCancellation(.canceled) - let cancelGate = CodexAppServerTestGate() - await transport.holdNextIgnoringCancellation( - .accountLoginCancel, - gate: cancelGate + try await transport.enqueueChatGPTLogin( + loginID: "login-runtime-start-admission", + authenticationURL: testAuthenticationURL ) - var appServerLifecycleStates: [Bool] = [] + try await transport.enqueueChatGPTLoginCancellation(.canceled) + var factoryCount = 0 + var closeCount = 0 let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - appServerLifecycleHandler: { container in - appServerLifecycleStates.append(container != nil) + environment: ["HOME": homeURL.path], + appServerCloser: { appServer in + closeCount += 1 + await appServer.close() }, - transport: transport + transportFactory: { _ in + factoryCount += 1 + return 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] - }) + await store.start(forceRestartIfNeeded: true) - #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) - await cancelGate.open() - await cancel - await stop + #expect(store.serverState == .running) + #expect(store.auth.isAuthenticating) + #expect(factoryCount == 1) + #expect(closeCount == 0) + #expect(await transport.recordedRequests(for: .accountLoginCancel).isEmpty) - #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) - #expect(store.auth.isAuthenticating == false) - #expect(store.serverURL == nil) + await store.cancelAuthentication() + await store.stop() } - @Test func liveStoreCancelsLoginBeforeIsolatedRuntimeFactoryReturns() async throws { + @Test func liveStoreLetsCancellationWinBeforeURLPresentationClaim() 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 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( @@ -1110,673 +3460,1018 @@ struct CodexReviewHostTests { )), requiresOpenAIAuth: false ) - try await mainTransport.enqueueRateLimits(try makeHostRateLimits( + 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 + }) + 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 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 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: 10 + usedPercent: 30 )) - 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 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": 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 + mcpHTTPServerFactory: { _, configuration, _ in + defer { mcpHTTPServerCount += 1 } + if mcpHTTPServerCount == 0 { + _ = configuration + return oldMCPHTTPServer } - return nextTransport - } + return newMCPHTTPServer + }, + mcpHTTPServerBindChecker: { _ in }, + appServerCloser: { _ in }, + transportFactory: { _ in transports.removeFirst() } ) - await store.start(forceRestartIfNeeded: true) - let add = Task { @MainActor in - try await store.addAccount() + let initialStart = Task { @MainActor in + await store.start(forceRestartIfNeeded: true) } - await lateRuntimeGate.waitUntilBlocked() - async let cancel: Void = store.cancelAuthentication() - await lateRuntimeGate.open() - try await add.value - await cancel + 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 + }) - let resolvedLateCodexHomeURL = try #require(lateCodexHomeURL) - #expect(externalURLOpener.openedURLs.isEmpty) - #expect(await lateTransport.recordedRequests(for: .accountLoginStart).isEmpty) - #expect(FileManager.default.fileExists(atPath: resolvedLateCodexHomeURL.path) == false) + 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 + }) + 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 - try await store.addAccount() - await nextTransport.waitForRequest(.accountLoginStart) - #expect(externalURLOpener.openedURLs == [testAuthenticationURL]) - await store.cancelAuthentication() + #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 liveStoreCancelsLoginAfterStartRequestBeforeHandleBinding() async throws { + @Test func liveStoreFailsClosedWhenRuntimeAuthenticationRegistryCommitIsUnresolved() 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"] + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueueAccount( + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", - planType: .pro + email: "second@example.com", + planType: .plus )), 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 + let corruption = RegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) - 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 - } + registryDestinationDidReplace: corruption.corruptIfArmed, + transport: transport ) 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 + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: mainCodexHomeURL.appendingPathComponent("auth.json") + ) + corruption.arm() + await store.refreshAuthentication() - 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) + #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 liveStoreUsesInjectedMonotonicDeadlineForLoginCancellationAcknowledgement() async throws { + @Test func liveStoreDoesNotRecordStaleRuntimeAccountAfterAClaimedEffectFails() 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 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 + 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 = BlockingRegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) - let deadlineClock = CodexAppServerTestDeadlineClock() let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], - deadlineClock: deadlineClock, + environment: ["HOME": homeURL.path], + registryDestinationDidReplace: replacement.blockCorruptAndFailIfArmed, transport: transport ) await store.start(forceRestartIfNeeded: true) - try await store.addAccount() - let cancel = Task { @MainActor in - await store.cancelAuthentication() + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + replacement.arm() + let refresh = Task { @MainActor in + await store.refreshAuthentication() } - await transport.waitForRequest(.accountLoginCancel) - try await deadlineClock.waitForSleeperCount(1) - deadlineClock.advance(by: .seconds(5)) - await cancel.value + await replacement.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: .pro + )) + replacement.open() + await refresh.value - #expect(store.auth.isAuthenticating == false) - #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + 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 liveStoreKeepsNewLoginGenerationWhenOldNotificationsArrive() async throws { + @Test func liveStoreFinalShutdownSupersedesRuntimeAuthenticationRead() async throws { + let homeURL = try temporaryHome() + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@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-old", authenticationURL: testAuthenticationURL) - try await transport.enqueueChatGPTLoginCancellation(.canceled) + 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 readGate = CodexAppServerTestGate() + let stopCompletion = CompletionFlag() let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": try temporaryHome().path], + environment: ["HOME": homeURL.path], + appServerCloser: { _ in }, transport: transport ) await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - try await store.addAccount() - await transport.waitForRequest(.accountLoginStart) - await store.cancelAuthentication() + 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() + } - try await transport.enqueueChatGPTLogin(loginID: "login-new", authenticationURL: testAuthenticationURL) - try await transport.enqueueChatGPTLoginCancellation(.canceled) + #expect(await waitUntil(timeout: .seconds(1)) { + await stopCompletion.isCompleted() + }) + #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) + } - 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") + @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"] + ) + 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 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.start(forceRestartIfNeeded: true) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") + ) + 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) - await store.cancelAuthentication() - #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 2) - await store.stop() + 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) } - @Test func liveStoreInstallsSessionBeforeAnAlreadyCompletedLoginRootRuns() async throws { + @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 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 + 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": try temporaryHome().path], + environment: ["HOME": homeURL.path], transport: transport ) await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - let firstLogin = Task { @MainActor in - try await store.addAccount() + await transport.holdNextIgnoringCancellation(.accountRead, gate: readGate) + let refresh = Task { @MainActor in + await store.refreshAuthentication() } - 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 readGate.waitUntilBlocked() + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .pro + )) + await readGate.open() + await refresh.value - await store.cancelAuthentication() - #expect(await transport.recordedRequests(for: .accountLoginCancel).count == 1) + #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 liveStoreAddsAccountWithoutSwitchingExistingActiveAccount() async throws { + @Test func liveStoreStopsOnceWhenConnectionFailsDuringRuntimeAuthRead() 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") + try writeRegistry( + homeURL: homeURL, + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueueAccount( + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", - planType: .pro + email: "second@example.com", + planType: .plus )), 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 readGate = CodexAppServerTestGate() + var closeCount = 0 + let store = CodexReviewStore.makeLiveStoreForTesting( + environment: ["HOME": homeURL.path], + appServerCloser: { _ in closeCount += 1 }, + transport: transport + ) - let authTransport = FakeCodexAppServerTransport() - try await authTransport.enqueueChatGPTLogin(loginID: "login-2", authenticationURL: testAuthenticationURL) - try await authTransport.enqueueAccount( + 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"] + ) + let transport = FakeCodexAppServerTransport() + try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "new@example.com", - planType: .plus + email: "first@example.com", + planType: .pro )), 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 await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "new@example.com", + email: "second@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() + 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], - 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() - } + transport: transport ) - 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 + 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 authTransport.notificationEmitter.emitAccountChanged(.init( + try await transport.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 - }) + await readGate.open() + await start.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(\.request.operation) == [ - .initialize, - .accountRead, - .accountRateLimitsRead, - ]) + #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 liveStoreDoesNotApplySavedAccountRateLimitsFromDifferentAuth() 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: "active@example.com", - accounts: ["active@example.com", "new@example.com"] + activeAccountKey: "first@example.com", + accounts: ["first@example.com"] ) - try writeSavedAccountAuth(homeURL: homeURL, accountKey: "new@example.com") - - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueueAccount( + let transport = FakeCodexAppServerTransport() + try await enqueueActiveAccountBootstrap(on: transport, email: "first@example.com") + try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", + email: "first@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 await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", - planType: .pro + email: "second@example.com", + planType: .plus )), requiresOpenAIAuth: false ) - try await refreshTransport.enqueueRateLimits(try makeHostRateLimits( - planType: nil, - windowDurationMinutes: 300, - usedPercent: 44 - )) - + let rateGate = CodexAppServerTestGate() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - 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 savedAuthBefore = try savedAccountAuth( + homeURL: homeURL, + accountKey: "first@example.com" + ) + 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(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, - ]) + #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 liveStoreAddAccountActivatesNewLoginWhenPersistedAccountsHaveNoActiveAccount() async throws { + @Test func liveStoreKeepsPrimaryLoginAdmissionUntilStablePublication() 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 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: "login-new", authenticationURL: testAuthenticationURL) + try await transport.enqueueChatGPTLogin( + loginID: "primary-admission", + authenticationURL: testAuthenticationURL + ) try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "new@example.com", + email: "second@example.com", planType: .plus )), requiresOpenAIAuth: false ) + 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: 20 + usedPercent: 15 )) - let externalURLOpener = FakeExternalURLOpener() + let replacement = BlockingRegistryReplacementProbe() let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - externalURLOpener: externalURLOpener.open, - transportFactory: { codexHomeURL in - #expect(codexHomeURL == mainCodexHomeURL) - return transport - } + registryDestinationDidReplace: replacement.blockIfArmed, + transport: 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") + 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: "login-new", + loginID: "primary-admission", completion: .succeeded ) try await transport.notificationEmitter.emitAccountChanged(.init( authMode: .chatGPT, planType: .plus )) - await transport.waitForRequestCount(7) + 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 {} + + 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 == "new@example.com" - && store.auth.selectedAccount?.rateLimits.first?.usedPercent == 20 + store.auth.selectedAccount?.accountKey == "third@example.com" }) - #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, - .accountRateLimitsRead, - ]) + #expect(try activeAccountKey(homeURL: homeURL) == "third@example.com") + #expect(FileManager.default.fileExists( + atPath: accountReconciliationDebtURL(homeURL: homeURL).path + ) == false) + await store.stop() } - @Test func liveStoreAddAccountCancelsLoginWhenOpeningURLFails() async throws { + @Test func liveStoreFailsPrimaryLoginClosedWithoutRecordingAnInvalidatedAccount() 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 codexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) + try writeRegistry(homeURL: homeURL, activeAccountKey: nil, accounts: []) + 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-invalidated-commit", + authenticationURL: testAuthenticationURL ) - let mainTransport = FakeCodexAppServerTransport() - try await mainTransport.enqueueAccount( + try await transport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", - planType: .pro + email: "second@example.com", + planType: .plus )), 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 replacement = BlockingRegistryReplacementCorruptingProbe( + registryURL: accountRegistryURL(homeURL: homeURL) ) 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 - } + registryDestinationDidReplace: replacement.blockCorruptAndFailIfArmed, + transport: transport ) 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() - 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") + try await store.addAccount() + await transport.waitForRequest(.accountLoginStart) + try Data(#"{"tokens":{"id_token":"second@example.com"}}"#.utf8).write( + to: codexHomeURL.appendingPathComponent("auth.json") ) - let transport = FakeCodexAppServerTransport() - try await transport.enqueueAccount( - try CodexAppServerTestAccount(kind: .chatGPT( - email: "active@example.com", - planType: .pro - )), - requiresOpenAIAuth: false + replacement.arm() + try await transport.notificationEmitter.emitLoginCompleted( + loginID: "primary-invalidated-commit", + completion: .succeeded ) - try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) - try await transport.enqueueModels(.init(models: [])) - try await transport.enqueueRateLimits(try makeHostRateLimits( - planType: .pro, - windowDurationMinutes: 300, - usedPercent: 10 + try await transport.notificationEmitter.emitAccountChanged(.init( + authMode: .chatGPT, + planType: .plus )) - let store = CodexReviewStore.makeLiveStoreForTesting( - environment: ["HOME": homeURL.path], - transport: transport + 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() - await store.start(forceRestartIfNeeded: true) - await transport.waitForNotificationStreamCount(1) - #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 + try #require(await waitUntil(timeout: .seconds(2)) { + if case .failed = store.serverState { return true } + return false }) - #expect(store.auth.selectedAccount?.planType == "pro") - #expect(store.auth.selectedAccount?.rateLimits.map(\.usedPercent) == [11]) + 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 { @@ -1850,7 +4545,7 @@ struct CodexReviewHostTests { 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.presentation.status == .cancelled) #expect(result.core.cancellation?.message == "Account switched.") @@ -1927,6 +4622,46 @@ struct CodexReviewHostTests { #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 { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) @@ -1939,24 +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.enqueueAccount( + let firstTransport = FakeCodexAppServerTransport() + try await firstTransport.enqueueAccount( try CodexAppServerTestAccount(kind: .chatGPT( email: "first@example.com", planType: .pro )), requiresOpenAIAuth: false ) - try await transport.enqueueConfiguration(try makeHostConfigurationReadResult()) - try await transport.enqueueModels(.init(models: [])) - try await transport.enqueueRateLimits(try makeHostRateLimits( + 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], - transport: transport + transportFactory: { _ in transports.removeFirst() } ) await store.start(forceRestartIfNeeded: true) @@ -1967,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 { @@ -2343,6 +5085,11 @@ struct CodexReviewHostTests { 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( @@ -2367,20 +5114,71 @@ struct CodexReviewHostTests { 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], + 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 + ), + 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 firstTransport.waitForRequestCount(6) + 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, @@ -2395,9 +5193,6 @@ struct CodexReviewHostTests { planType: nil, reachedType: nil ))) - #expect(await waitUntil(timeout: .seconds(1)) { - store.auth.selectedAccount?.rateLimits.first?.usedPercent == 12 - }) #expect( try Data(contentsOf: accountMutationJournalURL(homeURL: homeURL)) == journalData @@ -2406,7 +5201,6 @@ struct CodexReviewHostTests { authMode: .chatGPT, planType: .pro )) - await firstTransport.waitForRequest(.accountRead, count: 2) let recoveryHomeURL = try temporaryHome() let recoveryAccountsURL = recoveryHomeURL @@ -2446,6 +5240,9 @@ struct CodexReviewHostTests { 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) } @@ -2480,9 +5277,24 @@ struct CodexReviewHostTests { .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], - transport: transport + appServerLifecycleHandler: { container in + lifecycleStates.append(container != nil) + }, + transportFactory: { _ in transports.removeFirst() } ) await store.start(forceRestartIfNeeded: true) @@ -2504,22 +5316,22 @@ struct CodexReviewHostTests { atPath: accountMutationJournalURL(homeURL: homeURL).path ) == false ) + #expect(lifecycleStates == [true, false, true]) await store.stop() } - @Test func liveStoreClosesIsolatedLoginRuntimeWhenMainRuntimeIsUnavailable() async throws { + @Test func liveStoreRejectsAddAccountBeforeCreatingIsolatedRuntimeWhenMainRuntimeIsUnavailable() async throws { let homeURL = try temporaryHome() try writeRegistry( homeURL: homeURL, activeAccountKey: "active@example.com", accounts: ["active@example.com"] ) - var isolatedCodexHomeURL: URL? + var runtimeFactoryCallCount = 0 let store = CodexReviewStore.makeLiveStoreForTesting( environment: ["HOME": homeURL.path], - transportFactory: { codexHomeURL in - isolatedCodexHomeURL = codexHomeURL - try FileManager.default.createDirectory(at: codexHomeURL, withIntermediateDirectories: true) + transportFactory: { _ in + runtimeFactoryCallCount += 1 return FakeCodexAppServerTransport() } ) @@ -2527,13 +5339,13 @@ struct CodexReviewHostTests { do { try await store.addAccount() Issue.record("Expected unavailable main runtime to propagate to the add-account command.") - } catch let failure as CodexReviewAuthenticationFailure { - #expect(failure == .runtime(message: "Review runtime is not running.")) + } 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 { @@ -2706,6 +5518,164 @@ struct CodexReviewHostTests { } } +@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" + ) + } + } + + 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) + } + 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 + ) + + 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() + } + ) + + 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") + ) + } + + 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() +} + private extension CodexAppServerTestTransport { nonisolated var notificationEmitter: CodexAppServerTestNotificationEmitter { CodexAppServerTestNotificationEmitter(transport: self) @@ -2770,6 +5740,26 @@ private func makeHostRateLimits( ) } +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 + )) +} + private func makeHostStoredThread( id: CodexThreadID, model: String = "gpt-5", @@ -2822,7 +5812,7 @@ private final class FakeExternalURLOpener { self.failure = failure } - func open(_ url: URL) async throws { + func open(_ url: URL) throws { openedURLs.append(url) if let failure { throw failure @@ -2830,6 +5820,62 @@ private final class FakeExternalURLOpener { } } +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() + } + } + + func wait() async { + guard isSignaled == false else { + return + } + await withCheckedContinuation { continuation in + if isSignaled { + continuation.resume() + } else { + waiters.append(continuation) + } + } + } + + func snapshot() -> Bool { + isSignaled + } +} + +private actor ArmableAsyncHookGate { + private var armed = false + private let gate = CodexAppServerTestGate() + + func arm() { + armed = true + } + + func waitIfArmed() async { + guard armed else { return } + await gate.waitIgnoringCancellation() + } + + func waitUntilBlocked() async { + await gate.waitUntilBlocked() + } + + func open() async { + await gate.open() + } +} + private func temporaryHome() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("codex-review-host-tests-\(UUID().uuidString)", isDirectory: true) @@ -2936,6 +5982,44 @@ private func accountMutationJournalURL(homeURL: URL) -> URL { .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]) @@ -2984,6 +6068,237 @@ private enum MCPHTTPServerProbeError: Error, Sendable { case stagingFailed } +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 @@ -3020,6 +6335,7 @@ private final class MCPHTTPServerProbe: CodexReviewMCPHTTPServing, @unchecked Se private let endpoint: URL private let stageFailure: MCPHTTPServerProbeError? private let stageGate: CodexAppServerTestGate? + private let activationGate: CodexAppServerTestGate? private let stopGate: CodexAppServerTestGate? private let state = MCPHTTPServerProbeState() @@ -3027,11 +6343,13 @@ private final class MCPHTTPServerProbe: CodexReviewMCPHTTPServing, @unchecked Se 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 } @@ -3055,6 +6373,7 @@ private final class MCPHTTPServerProbe: CodexReviewMCPHTTPServing, @unchecked Se func activate() async { await state.recordActivation() + await activationGate?.waitIgnoringCancellation() } func stop() async { @@ -3130,3 +6449,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 } +} From 5de5b779c6434fb0d32768864f2e794326d4035f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:01:34 +0900 Subject: [PATCH 47/62] refactor(host): extract runtime and account owners --- .../CodexReviewHost/AccountRegistryDisk.swift | 1736 +++++++++++ .../AccountRegistryStore.swift | 462 +++ .../CodexReviewHost/HostRuntimeSession.swift | 430 +++ .../LiveCodexReviewStoreBackend.swift | 2611 ----------------- 4 files changed, 2628 insertions(+), 2611 deletions(-) create mode 100644 Sources/CodexReviewHost/AccountRegistryDisk.swift create mode 100644 Sources/CodexReviewHost/AccountRegistryStore.swift create mode 100644 Sources/CodexReviewHost/HostRuntimeSession.swift diff --git a/Sources/CodexReviewHost/AccountRegistryDisk.swift b/Sources/CodexReviewHost/AccountRegistryDisk.swift new file mode 100644 index 0000000..924b778 --- /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 0000000..5933065 --- /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/HostRuntimeSession.swift b/Sources/CodexReviewHost/HostRuntimeSession.swift new file mode 100644 index 0000000..937c49d --- /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 ed5558d..322e3a8 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1,6 +1,4 @@ import AppKit -import CryptoKit -import Darwin import Foundation import OSLog import CodexAppServerKit @@ -24,24 +22,6 @@ private enum RuntimeReviewCleanupMode { case connectionTerminated } -private struct HostRuntimeConsumerFailure: Error, LocalizedError, Sendable { - let message: String - - var errorDescription: String? { - message - } -} - -private struct IsolatedLoginProductCommitCancelled: Error, Sendable {} - -private struct IsolatedLoginProductCommitFailure: Error, LocalizedError, Sendable { - let failure: CodexReviewAuthenticationFailure - - var errorDescription: String? { - failure.localizedDescription - } -} - package struct CodexReviewMCPPortOwner: Equatable, Sendable { package var processIdentifier: Int32 package var command: String? @@ -3850,2597 +3830,6 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { return true } } - -} - -@MainActor -private struct AppServerRuntime: Sendable { - var appServer: CodexAppServer - var modelContainer: CodexModelContainer - var backend: AppServerCodexReviewBackend -} - -private struct HostRuntimeStopResult: Sendable { - var didReleaseResources: Bool - var didRetireRuns: Bool - var primaryAuthenticationHandoff: PrimaryAuthenticationReconciliationHandoff? -} - -private 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 - } - } -} - -private struct RuntimeAccountObservationAuthorization: Equatable, Sendable { - let generation: UInt64 - let revision: UInt64 - let observation: RuntimeAccountObservation -} - -@MainActor -private 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." - } - } - } -} - -actor AccountRegistryStore { - struct Snapshot: Sendable { - let accounts: [CodexSavedAccountPayload] - let activeAccountKey: String? - } - - struct MutationLease: Hashable, Sendable { - fileprivate let id: UUID - } - - struct AccountMutation: Sendable { - let lease: MutationLease - let before: Snapshot - } - - struct PreparedMutation: Hashable, Sendable { - fileprivate let id: UUID - } - - enum PreparedAbortDisposition: Sendable { - case restoredBefore(Snapshot) - case forwardedDesired(Snapshot) - } - - struct RuntimeCommitAuthorization: Sendable { - fileprivate 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 - - fileprivate 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 - } - } -} - -private 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) - ) - } } private typealias AppServerRuntimeFactory = @MainActor @Sendable (URL) async throws -> AppServerRuntime - -private 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: "-._~")) -} -} From c9c073fcd5395af04eeef71e50e3e780a2352925 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:17:25 +0900 Subject: [PATCH 48/62] build(deps): pin structured server shutdown fix --- Package.resolved | 9 ++++----- Package.swift | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Package.resolved b/Package.resolved index 52ea55a..b4399cb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "1b4f9ba4dfd5ce21c93da061f474e71cfc5d004e1efc94e9d4077a182a5654e2", + "originHash" : "b3a6dabd1e4dad734df56004218f2685e3d22440e187c4164870830f5bd2588a", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "bd4b53b303b4020a8add142282409486ec4b1741" + "revision" : "1ee509b31660d2491c71be4931d9e515126eec08" } }, { @@ -75,10 +75,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 7d325c8..6717178 100644 --- a/Package.swift +++ b/Package.swift @@ -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, From 5ebe0625c264bfc257f4bce05113e6f087870073 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:22:01 +0900 Subject: [PATCH 49/62] build(review-monitor): pin structured server shutdown fix --- .../xcshareddata/swiftpm/Package.resolved | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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 de72f79..e599fba 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" } }, { From 3f32a77b26640597d4b06031ae3a4a25d4fe0126 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:17:47 +0900 Subject: [PATCH 50/62] fix(mcp): remove redundant log metadata --- Docs/mcp.md | 21 ++-- .../CodexReviewMCPToolResults.swift | 114 ++---------------- .../ReviewMCPLogProjection.swift | 50 +------- .../CodexReviewMCPHTTPServerTests.swift | 60 ++++++--- .../CodexReviewMCPServerTests.swift | 1 - .../ReviewMCPLogProjectionTests.swift | 13 +- 6 files changed, 63 insertions(+), 196 deletions(-) diff --git a/Docs/mcp.md b/Docs/mcp.md index cc54a2a..03abd54 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/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index 91799b0..c99adfc 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -63,8 +63,7 @@ private extension CodexReviewAPI.Read.Result { func structuredContentForStartOrAwait(log: ReviewMCPLogProjection) -> Value { structuredContent( - includeLog: true, - includeDetails: false, + includeDetailedLog: false, includeNextAction: presentation.status.isTerminal == false, log: log ) @@ -72,16 +71,14 @@ private extension CodexReviewAPI.Read.Result { 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 { @@ -97,11 +94,8 @@ private extension CodexReviewAPI.Read.Result { resolvedFinalReview: log.finalResult ), ] - if includeLog { - object["log"] = - includeDetails - ? log.structuredContentWithItems() - : log.structuredContent() + if includeDetailedLog { + object["log"] = log.structuredContentWithItems() } if includeNextAction { object["nextAction"] = .object([ @@ -114,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, @@ -170,35 +127,12 @@ 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 } } @@ -262,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), @@ -313,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, diff --git a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift index 54d4004..b8f9a5d 100644 --- a/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift +++ b/Sources/CodexReviewMCPServer/ReviewMCPLogProjection.swift @@ -26,29 +26,17 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } } - var revision: String - var orderedEntryIDs: [String] - var activeEntryIDs: [String] - var activeEntryCount: Int - var latestEntryID: String? var turnID: CodexTurnID? - var finalLifecycleMessage: String? 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.rawValue):unavailable" + private init() { self.items = [] - self.orderedEntryIDs = [] - self.activeEntryIDs = [] - self.activeEntryCount = activeEntryIDs.count - self.latestEntryID = orderedEntryIDs.last self.turnID = nil - self.finalLifecycleMessage = nil self.finalResult = nil } @@ -69,27 +57,8 @@ 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.rawValue, - status.rawValue, - result.core.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.turnID = turnID - self.finalLifecycleMessage = status.isTerminal ? result.presentation.lifecycle.message : nil self.finalResult = status == .succeeded ? reviewOutputText.flatMap(Self.nonEmptyReviewOutput) @@ -104,19 +73,6 @@ package struct ReviewMCPLogProjection: Sendable, Equatable { } } -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 - } - return String(hash, radix: 16) - } -} - private extension ReviewMCPLogProjection.Content { init?(threadItem item: CodexThreadItem) { switch item.content { diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index d81c466..1356774 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -445,7 +445,41 @@ 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( @@ -585,7 +619,7 @@ 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") @@ -620,7 +654,7 @@ 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) } } @@ -1096,10 +1130,12 @@ 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) } } @@ -2186,20 +2222,6 @@ private func makeHTTPReviewAttempt(attemptID: String, turnID: String) -> ReviewA ) } -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 extension [String: Any] { func value(for path: [String]) -> Any? { var current: Any? = self diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index ae8efc1..c0da252 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -76,7 +76,6 @@ struct CodexReviewMCPServerTests { let log = snapshot.log #expect(read.runID.rawValue == "run-1") #expect(read.presentation.status == .succeeded) - #expect(log.finalLifecycleMessage == "Review completed.") #expect(log.finalResult == "No issues found.") #expect(log.items.isEmpty) } diff --git a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift index 3cda0f5..2d452ed 100644 --- a/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift +++ b/Tests/CodexReviewMCPServerTests/ReviewMCPLogProjectionTests.swift @@ -17,10 +17,6 @@ struct ReviewMCPLogProjectionTests { 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) } @@ -37,9 +33,6 @@ struct ReviewMCPLogProjectionTests { 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) } @@ -76,14 +69,11 @@ struct ReviewMCPLogProjectionTests { 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"]) } @@ -115,7 +105,6 @@ struct ReviewMCPLogProjectionTests { reviewOutputText: "CodexChat final" ) - #expect(projection.finalLifecycleMessage == "Review completed.") #expect(projection.finalResult == "CodexChat final") } } From a51e258cd97ea5c72c1bdb9a142731d4fbf694b2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:21:35 +0900 Subject: [PATCH 51/62] build(deps): pin CodexKit lifecycle fixes --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index b4399cb..198610c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "b3a6dabd1e4dad734df56004218f2685e3d22440e187c4164870830f5bd2588a", + "originHash" : "458e1d349e9a4bd42c38c670cfa3a8057f9a25b1e3d57b218fe3051cace0726c", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "1ee509b31660d2491c71be4931d9e515126eec08" + "revision" : "33fd8f5d3ae258607d8497b220f01bffa5dafeb2" } }, { diff --git a/Package.swift b/Package.swift index 6717178..bbcf1c8 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 = "1ee509b31660d2491c71be4931d9e515126eec08" +let codexKitFallbackRevision = "33fd8f5d3ae258607d8497b220f01bffa5dafeb2" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 3be09bb2f82e096fe0b89a4bf33732776fb20d3e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:14:07 +0900 Subject: [PATCH 52/62] fix(host): retain restart ownership during teardown --- .../AppServerCodexReviewBackend.swift | 39 +++++++++++ .../LiveCodexReviewStoreBackend.swift | 18 ++++-- .../Store/CodexReviewStoreCancellation.swift | 25 ++++++++ .../CodexReviewHostTests.swift | 64 +++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift index ccb0926..b748b13 100644 --- a/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift +++ b/Sources/CodexReviewAppServer/AppServerCodexReviewBackend.swift @@ -259,6 +259,45 @@ package actor AppServerCodexReviewBackend: CodexReviewBackend, CodexModelActor { } } + 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) } diff --git a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift index 322e3a8..d46bdf8 100644 --- a/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift +++ b/Sources/CodexReviewHost/LiveCodexReviewStoreBackend.swift @@ -1252,7 +1252,14 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { ) 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, @@ -1277,11 +1284,12 @@ private final class LiveCodexReviewStoreBackend: CodexReviewStoreBackend { } } session.retainPrimaryAuthenticationHandoffForStop(primaryAuthenticationHandoff) - if let appServer = runtime?.appServer { - let retainedRestartIdentities = await appServer.discardAllPreparedReviewRestarts() - precondition( - retainedRestartIdentities.values.allSatisfy(\.isEmpty), - "Review workers must transfer every prepared-restart identity before runtime teardown." + 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 diff --git a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift index 762a21f..9397e08 100644 --- a/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift +++ b/Sources/CodexReviewKit/Store/CodexReviewStoreCancellation.swift @@ -171,6 +171,31 @@ extension CodexReviewStore { ) } + 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 ) { diff --git a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift index 92edba1..ad9e9af 100644 --- a/Tests/CodexReviewHostTests/CodexReviewHostTests.swift +++ b/Tests/CodexReviewHostTests/CodexReviewHostTests.swift @@ -4961,6 +4961,70 @@ struct CodexReviewHostTests { #expect(methods.contains(.threadDelete) == false) } + @Test func liveStoreRetainsPreparedRestartOwnershipWhenConnectionTerminates() 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) + 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)) + 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) + + 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) + + #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) + } + @Test func liveStoreCleansIsolatedLoginRuntimeWhenMainNotificationStreamCloses() async throws { let homeURL = try temporaryHome() let mainCodexHomeURL = homeURL.appendingPathComponent(".codex_review", isDirectory: true) From c80f08218ad845575d51b8c98a6fc91c43c373c4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:28:59 +0900 Subject: [PATCH 53/62] build: advance CodexKit revision --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 198610c..97bebd5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "458e1d349e9a4bd42c38c670cfa3a8057f9a25b1e3d57b218fe3051cace0726c", + "originHash" : "45d58ae31fb7605bb6bd31f89e04f1f6104c4f6aed3368f5d716e67faf94dcb2", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "33fd8f5d3ae258607d8497b220f01bffa5dafeb2" + "revision" : "ee38d1b4f3a7c6208434ae57e039050fb2f30f3f" } }, { diff --git a/Package.swift b/Package.swift index bbcf1c8..a346f44 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 = "33fd8f5d3ae258607d8497b220f01bffa5dafeb2" +let codexKitFallbackRevision = "ee38d1b4f3a7c6208434ae57e039050fb2f30f3f" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 9ecea63cb50a37c2ec8ce9e2b499bf02f8987435 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:09:50 +0900 Subject: [PATCH 54/62] build: advance CodexKit revision Pin the fallback dependency and canonical resolved graph to the PR review-fix commit. --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 97bebd5..f6dd926 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "45d58ae31fb7605bb6bd31f89e04f1f6104c4f6aed3368f5d716e67faf94dcb2", + "originHash" : "445a633c74eb21668fac889201717bdc74c9b1612be7ca77dbde53756b597dbc", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "ee38d1b4f3a7c6208434ae57e039050fb2f30f3f" + "revision" : "02a6c29775d8610675c29636346df6d662188a9f" } }, { diff --git a/Package.swift b/Package.swift index a346f44..06a7b5a 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 = "ee38d1b4f3a7c6208434ae57e039050fb2f30f3f" +let codexKitFallbackRevision = "02a6c29775d8610675c29636346df6d662188a9f" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From ca05e65b6c73b09b90748485ca93a835200dfcd1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:24:29 +0900 Subject: [PATCH 55/62] build: advance CodexKit revision Pin the fallback dependency and canonical resolved graph to the latest PR review fixes. --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index f6dd926..e3787a2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "445a633c74eb21668fac889201717bdc74c9b1612be7ca77dbde53756b597dbc", + "originHash" : "65901b7ce6192fac6f7533d6026fb25cfcaef3f7f1431beef0889dcf0feacea2", "pins" : [ { "identity" : "codexkit", "kind" : "remoteSourceControl", "location" : "https://github.com/lynnswap/CodexKit.git", "state" : { - "revision" : "02a6c29775d8610675c29636346df6d662188a9f" + "revision" : "dce646bbd58b26aca46cb45662e34222786f36c0" } }, { diff --git a/Package.swift b/Package.swift index 06a7b5a..9bc1405 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 = "02a6c29775d8610675c29636346df6d662188a9f" +let codexKitFallbackRevision = "dce646bbd58b26aca46cb45662e34222786f36c0" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 45f325c71c91fc57dc61cdb608172bf43381ac41 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:41:15 +0900 Subject: [PATCH 56/62] fix(review-ui): stabilize persisted review presentation Reconstruct persisted reviewer rollouts from authoritative turn structure, keep incremental projection invalidation coherent, and display command durations only when app-server timing metadata is available. --- ...wMonitorCommandOutputDisplayDocument.swift | 13 +- .../ReviewMonitorCodexChatLogProjection.swift | 5 +- ...wMonitorCodexChatLogSourceProjection.swift | 245 ++++---- .../ReviewPresentationPolicies.swift | 105 +++- .../ReviewMonitorCodexChatDetailTests.swift | 539 +++++++++++++++++- Tests/ReviewUITests/ReviewUITests.swift | 50 ++ 6 files changed, 813 insertions(+), 144 deletions(-) diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 94ed256..02355a9 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 diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index 66ac1d9..aedad3b 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -156,8 +156,11 @@ struct ReviewMonitorCodexChatLogProjection { return nil } let presentationFacts = items.map(Self.presentationFacts) - let turnPolicy = ReviewTurnPresentationPolicy(items: presentationFacts) let rolloutPolicy = ReviewRolloutPresentationPolicy().evaluate(presentationFacts) + let turnPolicy = ReviewTurnPresentationPolicy( + items: presentationFacts, + additionalHiddenTurnIDs: rolloutPolicy.hiddenUserMessageTurnIDs + ) reportMissingRolloutTargets(rolloutPolicy.missingTargetSourceIDs) let blocks = items.flatMap { item in if rolloutPolicy.suppressedCompanionSourceIDs.contains(item.sourceID) { diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 063de2b..15e63ed 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -40,6 +40,7 @@ struct ReviewMonitorCodexChatLogSourceProjection { private var snapshot: CodexChatObservationSnapshot? private var cursor: Cursor? private var hasLogDocument = false + private var cachedPresentationState: ThreadPresentationState? private var projectedBlocksByLocator: [CodexChatItemLocator: [ReviewMonitorLogProjectedBlock]] = [:] @@ -48,6 +49,7 @@ struct ReviewMonitorCodexChatLogSourceProjection { snapshot = nil cursor = nil hasLogDocument = false + cachedPresentationState = nil projectedBlocksByLocator.removeAll(keepingCapacity: false) } @@ -97,13 +99,22 @@ struct ReviewMonitorCodexChatLogSourceProjection { 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 .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) - projectWholeTurn(turn, phase: snapshot.phase) + 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( @@ -115,38 +126,37 @@ struct ReviewMonitorCodexChatLogSourceProjection { turns.remove(at: previousIndex) precondition(turns.indices.contains(index) || index == turns.endIndex) turns.insert(turn, at: index) + presentationChanged = previousIndex != index + && cachedPresentationState?.hasExitedReviewMarker == true if previous.status != turn.status { - reprojectStatusDependentItems(in: turn, phase: snapshot.phase) + 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 removed = turns.remove(at: index) - for item in removed.items { - projectedBlocksByLocator.removeValue( - forKey: locator(for: item, turnID: removed.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) - let previous = turns[turnIndex] precondition( turns[turnIndex].items.indices.contains(index) || index == turns[turnIndex].items.endIndex ) turns[turnIndex].items.insert(item, at: index) - reconcileItemMutation( - previous: previous, - current: turns[turnIndex], - explicitlyChanged: [locator(for: item, turnID: turnID)], - removed: [], - phase: snapshot.phase - ) + 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 previous = turns[turnIndex] let previousIndex = requiredUniqueIndex(in: turns[turnIndex].items) { itemMatches($0, id: item.id, kind: item.kind) } @@ -158,41 +168,28 @@ struct ReviewMonitorCodexChatLogSourceProjection { turns[turnIndex].items.insert(item, at: index) let previousLocator = locator(for: previousItem, turnID: turnID) let currentLocator = locator(for: item, turnID: turnID) - reconcileItemMutation( - previous: previous, - current: turns[turnIndex], - explicitlyChanged: [currentLocator], - removed: previousLocator == currentLocator ? [] : [previousLocator], - phase: snapshot.phase - ) + 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 previous = turns[turnIndex] let itemIndex = requiredUniqueIndex(in: turns[turnIndex].items) { itemMatches($0, id: locator.id, kind: locator.kind) } - turns[turnIndex].items.remove(at: itemIndex) - reconcileItemMutation( - previous: previous, - current: turns[turnIndex], - explicitlyChanged: [], - removed: [locator], - phase: snapshot.phase - ) + 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 previous = turns[turnIndex] 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]) - reconcileItemMutation( - previous: previous, - current: turns[turnIndex], - explicitlyChanged: [locator], - removed: [], - phase: snapshot.phase - ) + explicitlyChanged.insert(locator) case .statusChanged(let status): snapshot.thread.status = status case .phaseChanged(let phase): @@ -200,28 +197,45 @@ struct ReviewMonitorCodexChatLogSourceProjection { snapshot.phase = phase for turnID in affectedTurnIDs { let turnIndex = requiredTurnIndex(turnID, in: turns) - reprojectStatusDependentItems(in: turns[turnIndex], phase: phase) + 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) + projectWholeTurn( + turn, + phase: snapshot.phase, + presentation: presentation + ) } } private mutating func projectWholeTurn( _ turn: CodexTurnSnapshot, - phase: CodexChatPhase + phase: CodexChatPhase, + presentation: ThreadPresentationState ) { var seen: Set = [] - let presentation = presentationState(for: turn, phase: phase, reportMissing: true) for item in turn.items { let locator = locator(for: item, turnID: turn.id) precondition(seen.insert(locator).inserted) @@ -230,80 +244,77 @@ struct ReviewMonitorCodexChatLogSourceProjection { } } - private mutating func reconcileItemMutation( - previous: CodexTurnSnapshot, - current: CodexTurnSnapshot, + private mutating func reconcileProjection( + previous: CodexChatObservationSnapshot, + current: CodexChatObservationSnapshot, explicitlyChanged: Set, removed: Set, - phase: CodexChatPhase + presentationChanged: Bool ) { - precondition(previous.id == current.id) - let previousPresentation = presentationState( - for: previous, - phase: phase, - reportMissing: false - ) - let currentPresentation = presentationState( - for: current, - phase: phase, - reportMissing: true - ) + 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 previousPresentation.hidesUserMessages != currentPresentation.hidesUserMessages { - for item in current.items where item.isUserMessage { - locatorsToReproject.insert(locator(for: item, turnID: current.id)) - } - } - - let changedSuppressionSourceIDs = - previousPresentation.suppressedRolloutSourceIDs - .symmetricDifference(currentPresentation.suppressedRolloutSourceIDs) - if changedSuppressionSourceIDs.isEmpty == false { - for item in current.items { - let facts = logProjection.presentationFacts( - for: item, - turnID: current.id, - turnStatus: projectedStatus(for: current, phase: phase) - ) - if changedSuppressionSourceIDs.contains(facts.sourceID) { - locatorsToReproject.insert(locator(for: item, turnID: current.id)) + 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 itemIndex = requiredUniqueIndex(in: current.items) { + 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( - current.items[itemIndex], - in: current, - phase: phase, + turn.items[itemIndex], + in: turn, + phase: current.phase, presentation: currentPresentation ) } } - private mutating func reprojectStatusDependentItems( - in turn: CodexTurnSnapshot, - phase: CodexChatPhase - ) { - let presentation = presentationState(for: turn, phase: phase, reportMissing: true) - for item in turn.items where logProjection.dependsOnTurnStatus(item) { - reproject(item, in: turn, phase: phase, presentation: presentation) - } - } - private mutating func reproject( _ item: CodexThreadItem, in turn: CodexTurnSnapshot, phase: CodexChatPhase, - presentation: TurnPresentationState + presentation: ThreadPresentationState ) { let status = projectedStatus(for: turn, phase: phase) let facts = logProjection.presentationFacts( @@ -318,29 +329,37 @@ struct ReviewMonitorCodexChatLogSourceProjection { turnStatus: status, chatCreatedAt: snapshot?.thread.createdAt, chatUpdatedAt: snapshot?.thread.updatedAt, - suppressUserMessage: presentation.hidesUserMessages, + suppressUserMessage: presentation.hidesUserMessage(in: turn.id), suppressRolloutCompanion: presentation.suppressedRolloutSourceIDs.contains(facts.sourceID) ) } private mutating func presentationState( - for turn: CodexTurnSnapshot, - phase: CodexChatPhase, + for snapshot: CodexChatObservationSnapshot, reportMissing: Bool - ) -> TurnPresentationState { - let status = projectedStatus(for: turn, phase: phase) - let facts = turn.items.map { - logProjection.presentationFacts(for: $0, turnID: turn.id, turnStatus: status) + ) -> 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( - hidesUserMessages: ReviewTurnPresentationPolicy(items: facts) - .hidesUserMessage(in: turn.id), - suppressedRolloutSourceIDs: rollout.suppressedCompanionSourceIDs + userMessagePolicy: ReviewTurnPresentationPolicy( + items: facts, + additionalHiddenTurnIDs: rollout.hiddenUserMessageTurnIDs + ), + suppressedRolloutSourceIDs: rollout.suppressedCompanionSourceIDs, + hasExitedReviewMarker: facts.contains { $0.kind == .exitedReviewMode } ) } @@ -401,6 +420,19 @@ struct ReviewMonitorCodexChatLogSourceProjection { 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] @@ -509,9 +541,14 @@ struct ReviewMonitorCodexChatLogSourceProjection { return .clear } - private struct TurnPresentationState { - var hidesUserMessages: Bool + private struct ThreadPresentationState { + var userMessagePolicy: ReviewTurnPresentationPolicy var suppressedRolloutSourceIDs: Set + var hasExitedReviewMarker: Bool + + func hidesUserMessage(in turnID: CodexTurnID) -> Bool { + userMessagePolicy.hidesUserMessage(in: turnID) + } } } diff --git a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift index d9be3b0..e3c9419 100644 --- a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift +++ b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift @@ -12,10 +12,13 @@ struct ReviewItemPresentationFacts: Equatable, Sendable { } struct ReviewTurnPresentationPolicy: Sendable { - private let markedTurnIDs: Set + private let hiddenUserMessageTurnIDs: Set - init(items: [ReviewItemPresentationFacts]) { - markedTurnIDs = Set(items.compactMap { item in + init( + items: [ReviewItemPresentationFacts], + additionalHiddenTurnIDs: Set = [] + ) { + hiddenUserMessageTurnIDs = additionalHiddenTurnIDs.union(items.compactMap { item in guard item.kind == .enteredReviewMode || item.kind == .exitedReviewMode else { return nil } @@ -27,43 +30,80 @@ struct ReviewTurnPresentationPolicy: Sendable { guard let turnID else { return false } - return markedTurnIDs.contains(turnID) + 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 { - let targetTextsByTurnID = Dictionary(grouping: items.compactMap { item -> Target? in + let targets = items.compactMap { item -> Target? in guard item.kind == .exitedReviewMode, let turnID = item.turnID else { return nil } return Target(turnID: turnID, normalizedText: normalized(item.displayText)) - }, by: \.turnID) + } + let persistedCompanionTurnIDs = persistedCompanionTurnIDs( + in: items, + targetTurnIDs: Set(targets.map(\.turnID)) + ) + let hasReviewMarker = items.contains { + $0.kind == .enteredReviewMode || $0.kind == .exitedReviewMode + } - var suppressed: Set = [] - var missingTargets: Set = [] - for item in items where isReviewRolloutCompanion(item) { - guard let turnID = item.turnID, - let targets = targetTextsByTurnID[turnID], - targets.isEmpty == false + var suppressed: Set = Set(items.compactMap { item -> String? in + guard item.kind == .agentMessage, + let turnID = item.turnID, + persistedCompanionTurnIDs.contains(turnID) else { - missingTargets.insert(item.sourceID) + return nil + } + return item.sourceID + }) + var hiddenUserMessageTurnIDs = persistedCompanionTurnIDs + var missingTargets: Set = [] + for item in items where item.kind == .agentMessage { + let isTypedCompanion = isReviewRolloutCompanion(item) + let isPersistedCompanionCandidate = item.turnID.map { + persistedCompanionTurnIDs.contains($0) + } ?? false + guard isTypedCompanion || isPersistedCompanionCandidate else { + continue + } + + if isTypedCompanion, hasReviewMarker, let turnID = item.turnID { + hiddenUserMessageTurnIDs.insert(turnID) + } + guard targets.isEmpty == false else { + if isTypedCompanion { + missingTargets.insert(item.sourceID) + } continue } guard let companionText = normalized(item.displayText) else { continue } - if targets.contains(where: { $0.normalizedText == companionText }) { + let matchingTarget = targets.first { target in + guard target.normalizedText == companionText else { + return false + } + return isTypedCompanion || target.turnID != item.turnID + } + if matchingTarget != nil { suppressed.insert(item.sourceID) + if let turnID = item.turnID { + hiddenUserMessageTurnIDs.insert(turnID) + } } } return .init( suppressedCompanionSourceIDs: suppressed, + hiddenUserMessageTurnIDs: hiddenUserMessageTurnIDs, missingTargetSourceIDs: missingTargets ) } @@ -74,6 +114,43 @@ struct ReviewRolloutPresentationPolicy: Sendable { && item.semanticRelation == .companionOf(.exitedReviewMode) } + private func persistedCompanionTurnIDs( + in items: [ReviewItemPresentationFacts], + targetTurnIDs: Set + ) -> Set { + var orderedTurnIDs: [CodexTurnID] = [] + var seenTurnIDs: Set = [] + for turnID in items.compactMap(\.turnID) where seenTurnIDs.insert(turnID).inserted { + orderedTurnIDs.append(turnID) + } + let itemsByTurnID = Dictionary(grouping: items, by: \.turnID) + + var result: Set = [] + for targetTurnID in targetTurnIDs { + guard let targetIndex = orderedTurnIDs.firstIndex(of: targetTurnID), + orderedTurnIDs.indices.contains(targetIndex + 1) + else { + continue + } + let candidateTurnID = orderedTurnIDs[targetIndex + 1] + let candidateItems = itemsByTurnID[candidateTurnID] ?? [] + let userTexts = candidateItems.compactMap { item -> String? in + guard item.kind == .userMessage else { + return nil + } + return normalized(item.displayText) + } + let hasRepeatedUserPrompt = Dictionary(grouping: userTexts, by: { $0 }) + .values + .contains { $0.count > 1 } + let agentMessageCount = candidateItems.count { $0.kind == .agentMessage } + if hasRepeatedUserPrompt && agentMessageCount == 1 { + result.insert(candidateTurnID) + } + } + return result + } + private func normalized(_ text: String?) -> String? { guard let value = text?.trimmingCharacters(in: .whitespacesAndNewlines), value.isEmpty == false diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index c127a2f..96cebe4 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -497,6 +497,87 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.text.contains("Keep this prompt visible.") == true) } + @Test func codexChatLogProjectionReconstructsPersistedReviewAcrossTurns() + 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: "item-3", + kind: .agentMessage, + content: .message(.init( + id: "item-3", + 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 = """ @@ -534,38 +615,133 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.blocks.count == 1) } + @Test func codexChatLogProjectionReconstructsInterruptedPersistedReviewAcrossTurns() + async throws + { + var projection = ReviewMonitorCodexChatLogProjection() + 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: "outer-interrupted-review-turn"), + state: .interrupted, + items: [ + .init( + id: "entered-review", + kind: .enteredReviewMode, + content: .log("current changes") + ), + .init( + id: "review-output", + kind: .exitedReviewMode, + 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: "item-3", + kind: .agentMessage, + content: .message(.init( + id: "item-3", + role: .assistant, + 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 turn = CodexTurnSnapshot( - id: CodexTurnID(rawValue: "turn-review"), - state: .completed, - items: [ + let snapshot = makeCodexThreadSnapshotForTesting( + chatID: CodexThreadID(rawValue: "review-thread"), + turns: [ .init( - id: "review-output", - kind: .exitedReviewMode, - content: .log(finalReview) + id: CodexTurnID(rawValue: "turn-review"), + state: .completed, + items: [ + .init( + id: "review-output", + kind: .exitedReviewMode, + content: .log(finalReview) + ), + ] ), .init( - id: "ordinary-assistant", - kind: .agentMessage, - content: .message(.init( - id: "ordinary-assistant", - role: .assistant, - text: finalReview - )) + 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: "ordinary-assistant", + kind: .agentMessage, + content: .message(.init( + id: "ordinary-assistant", + role: .assistant, + text: finalReview + )) + ), + ] ), ] ) let document = projection.render( - from: turn, + from: snapshot, chatCreatedAt: nil, chatUpdatedAt: nil ) - #expect(document?.blocks.count == 2) - #expect(document?.text == "\(finalReview)\n\n\(finalReview)") + #expect(document?.blocks.count == 3) + #expect( + document?.text + == "\(finalReview)\n\nRepeat the review result.\n\n\(finalReview)" + ) } @Test func codexChatLogProjectionKeepsReviewRolloutCompanionWithDifferentText() async throws { @@ -1567,6 +1743,335 @@ struct ReviewMonitorCodexChatDetailTests { #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: outerTurnID, + state: .inProgress, + items: [.init( + id: "entered-review-stream", + kind: .enteredReviewMode, + content: .log("current changes") + )] + ), + .init( + id: innerTurnID, + state: .completed, + items: [ + .init( + id: "review-user-stream", + kind: .userMessage, + content: .message(.init( + 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 codexChatSourceProjectionReevaluatesPersistedReviewPolicyAfterTurnMove() 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-agent-move", + kind: .agentMessage, + content: .message(.init( + id: "review-agent-move", + 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.contains("Review the current changes.") == true) + + 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 codexChatSourceProjectionReevaluatesPersistedReviewPolicyForStructuralTurnChanges() 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-agent-structural-change", + kind: .agentMessage, + content: .message(.init( + id: "review-agent-structural-change", + 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.contains("Review the current changes.") == true) + #expect(inserted?.sourceDocument?.text.contains("Review was interrupted.") == true) + + let removed = projection.apply(.init( + generation: 1, + sequence: 2, + payload: .update(.turnRemoved(id: interveningTurnID)) + )) + #expect(removed?.sourceDocument?.text == reviewOutput) + } + + @Test func codexChatSourceProjectionKeepsValidatedPersistedCompanionSuppressedAfterTextAppend() + 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: "generic-review-agent-append", + kind: .agentMessage, + content: .message(.init( + id: "generic-review-agent-append", + 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: companionTurnID, + state: .inProgress, + items: [ + .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." + )) + )) + + #expect(completed?.sourceDocument?.text == reviewOutput) + #expect(completed?.sourceDocument?.blocks.count == 1) + } + @Test func codexChatSourceProjectionAppliesPhaseOnlyToStatusDependentBlocks() throws { var projection = ReviewMonitorCodexChatLogSourceProjection() let turnID = CodexTurnID(rawValue: "turn-phase") diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index db1cd00..647e363 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2870,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() From fd0a4729227ff87adaa4da3feb23bf21ff8d8df2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:03:35 +0900 Subject: [PATCH 57/62] fix --- Package.resolved | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Package.resolved b/Package.resolved index e3787a2..70425b5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,6 @@ { - "originHash" : "65901b7ce6192fac6f7533d6026fb25cfcaef3f7f1431beef0889dcf0feacea2", + "originHash" : "6c55e0749603b3a3418923fe53a26d7fca40e9ae5808ac604c91bb89a9212236", "pins" : [ - { - "identity" : "codexkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lynnswap/CodexKit.git", - "state" : { - "revision" : "dce646bbd58b26aca46cb45662e34222786f36c0" - } - }, { "identity" : "eventsource", "kind" : "remoteSourceControl", From 4eddf5ee9994c5ae5c06b8b661cec5a69679001d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:34:17 +0900 Subject: [PATCH 58/62] fix(review-ui): consume normalized rollout companions Remove persisted text and adjacency heuristics from ReviewMonitor. Suppress typed companion messages only within the review sequence owned by the nearest exited-review marker. --- .../ReviewMonitorCodexChatLogProjection.swift | 2 +- .../ReviewPresentationPolicies.swift | 112 ++---------------- .../ReviewMonitorCodexChatDetailTests.swift | 88 ++++++++++---- 3 files changed, 74 insertions(+), 128 deletions(-) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift index aedad3b..c907870 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogProjection.swift @@ -321,7 +321,7 @@ struct ReviewMonitorCodexChatLogProjection { for sourceID in sourceIDs where reportedMissingRolloutTargetSourceIDs.insert(sourceID).inserted { projectionLogger.error( - "Review rollout companion has no exited-review target in its turn: \(sourceID, privacy: .public)" + "Review rollout companion has no exited-review target in its review sequence: \(sourceID, privacy: .public)" ) } } diff --git a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift index e3c9419..c8bfb93 100644 --- a/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift +++ b/Sources/ReviewChatLogUI/ReviewPresentationPolicies.swift @@ -42,64 +42,21 @@ struct ReviewRolloutPresentationPolicy: Sendable { } func evaluate(_ items: [ReviewItemPresentationFacts]) -> Result { - let targets = items.compactMap { item -> Target? in - guard item.kind == .exitedReviewMode, let turnID = item.turnID else { - return nil - } - return Target(turnID: turnID, normalizedText: normalized(item.displayText)) - } - let persistedCompanionTurnIDs = persistedCompanionTurnIDs( - in: items, - targetTurnIDs: Set(targets.map(\.turnID)) - ) - let hasReviewMarker = items.contains { - $0.kind == .enteredReviewMode || $0.kind == .exitedReviewMode - } - - var suppressed: Set = Set(items.compactMap { item -> String? in - guard item.kind == .agentMessage, - let turnID = item.turnID, - persistedCompanionTurnIDs.contains(turnID) - else { - return nil - } - return item.sourceID - }) - var hiddenUserMessageTurnIDs = persistedCompanionTurnIDs + var suppressed: Set = [] + var hiddenUserMessageTurnIDs: Set = [] var missingTargets: Set = [] - for item in items where item.kind == .agentMessage { - let isTypedCompanion = isReviewRolloutCompanion(item) - let isPersistedCompanionCandidate = item.turnID.map { - persistedCompanionTurnIDs.contains($0) - } ?? false - guard isTypedCompanion || isPersistedCompanionCandidate else { - continue + for (index, item) in items.enumerated() where isReviewRolloutCompanion(item) { + let target = items[.. - ) -> Set { - var orderedTurnIDs: [CodexTurnID] = [] - var seenTurnIDs: Set = [] - for turnID in items.compactMap(\.turnID) where seenTurnIDs.insert(turnID).inserted { - orderedTurnIDs.append(turnID) - } - let itemsByTurnID = Dictionary(grouping: items, by: \.turnID) - - var result: Set = [] - for targetTurnID in targetTurnIDs { - guard let targetIndex = orderedTurnIDs.firstIndex(of: targetTurnID), - orderedTurnIDs.indices.contains(targetIndex + 1) - else { - continue - } - let candidateTurnID = orderedTurnIDs[targetIndex + 1] - let candidateItems = itemsByTurnID[candidateTurnID] ?? [] - let userTexts = candidateItems.compactMap { item -> String? in - guard item.kind == .userMessage else { - return nil - } - return normalized(item.displayText) - } - let hasRepeatedUserPrompt = Dictionary(grouping: userTexts, by: { $0 }) - .values - .contains { $0.count > 1 } - let agentMessageCount = candidateItems.count { $0.kind == .agentMessage } - if hasRepeatedUserPrompt && agentMessageCount == 1 { - result.insert(candidateTurnID) - } - } - return result - } - - private func normalized(_ text: String?) -> String? { - guard let value = text?.trimmingCharacters(in: .whitespacesAndNewlines), - value.isEmpty == false - else { - return nil - } - return value - } - - private struct Target: Sendable { - var turnID: CodexTurnID - var normalizedText: String? - } } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index 96cebe4..f6469e1 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -497,7 +497,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.text.contains("Keep this prompt visible.") == true) } - @Test func codexChatLogProjectionReconstructsPersistedReviewAcrossTurns() + @Test func codexChatLogProjectionConsumesNormalizedReviewAcrossTurns() async throws { var projection = ReviewMonitorCodexChatLogProjection() @@ -555,10 +555,10 @@ struct ReviewMonitorCodexChatDetailTests { )) ), .init( - id: "item-3", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "item-3", + id: "review_rollout_assistant", role: .assistant, text: finalReview )) @@ -615,7 +615,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document?.blocks.count == 1) } - @Test func codexChatLogProjectionReconstructsInterruptedPersistedReviewAcrossTurns() + @Test func codexChatLogProjectionConsumesNormalizedInterruptedReviewAcrossTurns() async throws { var projection = ReviewMonitorCodexChatLogProjection() @@ -664,10 +664,10 @@ struct ReviewMonitorCodexChatDetailTests { )) ), .init( - id: "item-3", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "item-3", + id: "review_rollout_assistant", role: .assistant, text: "Review was interrupted. Please re-run /review." )) @@ -744,7 +744,9 @@ struct ReviewMonitorCodexChatDetailTests { ) } - @Test func codexChatLogProjectionKeepsReviewRolloutCompanionWithDifferentText() async throws { + @Test func codexChatLogProjectionSuppressesTypedReviewRolloutCompanionWithDifferentText() + async throws + { var projection = ReviewMonitorCodexChatLogProjection() let turn = CodexTurnSnapshot( id: CodexTurnID(rawValue: "turn-review"), @@ -773,9 +775,8 @@ struct ReviewMonitorCodexChatDetailTests { chatUpdatedAt: nil ) - #expect(document?.blocks.count == 2) - #expect(document?.text.contains("Review failed before producing findings.") == true) - #expect(document?.text.contains("The review child was interrupted.") == true) + #expect(document?.blocks.count == 1) + #expect(document?.text == "Review failed before producing findings.") } @Test func codexChatLogProjectionKeepsReviewRolloutCompanionWhenTargetIsMissing() async throws { @@ -821,6 +822,43 @@ struct ReviewMonitorCodexChatDetailTests { ) } + @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." @@ -1731,8 +1769,7 @@ struct ReviewMonitorCodexChatDetailTests { )) )) #expect( - different?.sourceDocument?.text - == "Review stopped before producing findings.\n\nNo issues found." + different?.sourceDocument?.text == "Review stopped before producing findings." ) let removed = projection.apply(.init( @@ -1820,7 +1857,9 @@ struct ReviewMonitorCodexChatDetailTests { #expect(document.blocks.contains { $0.id.rawValue.contains("review_rollout_assistant") } == false) } - @Test func codexChatSourceProjectionReevaluatesPersistedReviewPolicyAfterTurnMove() throws { + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAfterTurnMove() + throws + { var projection = ReviewMonitorCodexChatLogSourceProjection() let outerTurnID = CodexTurnID(rawValue: "outer-review-turn-move") let interveningTurnID = CodexTurnID(rawValue: "intervening-turn-move") @@ -1871,10 +1910,10 @@ struct ReviewMonitorCodexChatDetailTests { )) ), .init( - id: "review-agent-move", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "review-agent-move", + id: "review_rollout_assistant", role: .assistant, text: "Review was interrupted." )) @@ -1892,7 +1931,7 @@ struct ReviewMonitorCodexChatDetailTests { phase: .terminal(turnID: companionTurnID, disposition: .completed) ), reason: .initial) )) - #expect(initial?.sourceDocument?.text.contains("Review the current changes.") == true) + #expect(initial?.sourceDocument?.text == "\(reviewOutput)\n\nIntervening output") let moved = projection.apply(.init( generation: 1, @@ -1903,7 +1942,9 @@ struct ReviewMonitorCodexChatDetailTests { #expect(moved?.sourceDocument?.text == "\(reviewOutput)\n\nIntervening output") } - @Test func codexChatSourceProjectionReevaluatesPersistedReviewPolicyForStructuralTurnChanges() throws { + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAcrossStructuralChanges() + throws + { var projection = ReviewMonitorCodexChatLogSourceProjection() let outerTurnID = CodexTurnID(rawValue: "outer-review-structural-change") let companionTurnID = CodexTurnID(rawValue: "companion-review-structural-change") @@ -1941,10 +1982,10 @@ struct ReviewMonitorCodexChatDetailTests { )) ), .init( - id: "review-agent-structural-change", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "review-agent-structural-change", + id: "review_rollout_assistant", role: .assistant, text: "Review was interrupted." )) @@ -1982,8 +2023,7 @@ struct ReviewMonitorCodexChatDetailTests { sequence: 1, payload: .update(.turnInserted(interveningTurn, index: 1)) )) - #expect(inserted?.sourceDocument?.text.contains("Review the current changes.") == true) - #expect(inserted?.sourceDocument?.text.contains("Review was interrupted.") == true) + #expect(inserted?.sourceDocument?.text == "\(reviewOutput)\n\n$ /usr/bin/true") let removed = projection.apply(.init( generation: 1, @@ -1993,7 +2033,7 @@ struct ReviewMonitorCodexChatDetailTests { #expect(removed?.sourceDocument?.text == reviewOutput) } - @Test func codexChatSourceProjectionKeepsValidatedPersistedCompanionSuppressedAfterTextAppend() + @Test func codexChatSourceProjectionKeepsNormalizedCompanionSuppressedAfterTextAppend() throws { var projection = ReviewMonitorCodexChatLogSourceProjection() @@ -2001,10 +2041,10 @@ struct ReviewMonitorCodexChatDetailTests { let companionTurnID = CodexTurnID(rawValue: "companion-review-text-append") let reviewOutput = "No actionable issues were identified." let companion = CodexThreadItem( - id: "generic-review-agent-append", + id: "review_rollout_assistant", kind: .agentMessage, content: .message(.init( - id: "generic-review-agent-append", + id: "review_rollout_assistant", role: .assistant, text: "No actionable issues were" )) From 3c37d5bb2e181e0de2a8cd52326505a78e1008f8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:02:49 +0900 Subject: [PATCH 59/62] fix(ui): refresh review presentation after turn moves --- ...wMonitorCodexChatLogSourceProjection.swift | 2 +- .../AppServerClientTests.swift | 13 +++- .../ReviewMonitorCodexChatDetailTests.swift | 59 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift index 15e63ed..3b549a1 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogSourceProjection.swift @@ -127,7 +127,7 @@ struct ReviewMonitorCodexChatLogSourceProjection { precondition(turns.indices.contains(index) || index == turns.endIndex) turns.insert(turn, at: index) presentationChanged = previousIndex != index - && cachedPresentationState?.hasExitedReviewMarker == true + && turn.items.contains(where: itemAffectsPresentation) if previous.status != turn.status { statusDependent.formUnion(turn.items.compactMap { item in logProjection.dependsOnTurnStatus(item) diff --git a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift index edf068a..171c486 100644 --- a/Tests/CodexReviewAppServerTests/AppServerClientTests.swift +++ b/Tests/CodexReviewAppServerTests/AppServerClientTests.swift @@ -228,7 +228,8 @@ struct AppServerClientTests { on: runtime, threadID: "thread-1", turnID: "turn-1", - state: .completed + state: .completed, + itemsLoadState: .notLoaded ) #expect( @@ -1178,12 +1179,14 @@ private extension CodexAppServerTestTransport { 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 @@ -1195,11 +1198,17 @@ private func emitTurn( 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, items: items) + turn: makeTestTurn( + id: turnID, + state: state, + itemsLoadState: itemsLoadState, + items: items + ) ) } diff --git a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift index f6469e1..4308230 100644 --- a/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift +++ b/Tests/ReviewUITests/ReviewMonitorCodexChatDetailTests.swift @@ -1704,6 +1704,65 @@ struct ReviewMonitorCodexChatDetailTests { #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") From d20750c4a6c6ed32db5111e4625a0510d2e1efef Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:28:54 +0900 Subject: [PATCH 60/62] build(deps): pin merged CodexKit review fixes --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 9bc1405..5b43717 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 = "dce646bbd58b26aca46cb45662e34222786f36c0" +let codexKitFallbackRevision = "2ec1fd20907ed25dca0dcf0f7ea754e57953ad29" let codexKitDependency: Package.Dependency = FileManager.default.fileExists(atPath: "\(localCodexKitPath)/Package.swift") ? .package(path: localCodexKitPath) From 9fff09c2ac1b163d4e1872a6663c34277fd2c3e7 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:19:35 +0900 Subject: [PATCH 61/62] fix(review): address PR feedback and shard CI Route authentication operation failures through user-visible UI state, resolve successful list and completed cancel output from the authoritative chat projection, and pin the API gate toolchain. Split build and test execution into cached ten-minute jobs so CI no longer spends the package-test budget on unrelated gates. --- .github/workflows/ci.yml | 186 ++++++++++++++---- .../CodexReviewMCPServer.swift | 92 ++++++++- .../CodexReviewMCPToolResults.swift | 46 +++-- .../ReviewMonitorAddAccountAction.swift | 30 ++- Sources/ReviewUI/SignIn/SignInView.swift | 4 +- .../CodexReviewMCPHTTPServerTests.swift | 93 ++++++++- .../CodexReviewMCPServerTests.swift | 53 +++++ .../ReviewMonitorAddAccountActionTests.swift | 20 ++ scripts/restore-source-mtimes.sh | 30 +++ scripts/verify-ci-xcode.sh | 26 +++ 10 files changed, 488 insertions(+), 92 deletions(-) create mode 100644 Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift create mode 100755 scripts/restore-source-mtimes.sh create mode 100755 scripts/verify-ci-xcode.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a23905a..b7f2e7f 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,52 +39,151 @@ jobs: - name: Verify GitHub Actions pinning run: ruby scripts/verify-github-actions-pinning.rb - - name: Resolve latest stable Xcode - 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 - run: | - xcodebuild -version - xcodebuild -showsdks + - name: Verify pinned Xcode + run: scripts/verify-ci-xcode.sh - name: Verify TextTransitions public API run: scripts/verify-text-transitions-api.sh - - name: Run Swift package tests - run: swift test --build-system swiftbuild --no-parallel + 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: + shard: + - CodexReviewKitTests + - CodexReviewAppServerTests + - CodexReviewMCPServerTests + - CodexReviewHostTests + - ReviewUITests + - remaining + 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 + run: | + if [[ "${{ matrix.shard }}" == "remaining" ]]; then + swift test \ + --skip-build \ + --build-system swiftbuild \ + --no-parallel \ + --skip 'CodexReviewKitTests|CodexReviewAppServerTests|CodexReviewMCPServerTests|CodexReviewHostTests|ReviewUITests' + else + swift test \ + --skip-build \ + --build-system swiftbuild \ + --no-parallel \ + --filter "${{ matrix.shard }}" + fi + + 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 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: 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/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift index d2bff44..f0cbd3f 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPServer.swift @@ -53,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 { @@ -60,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) } } @@ -125,23 +159,25 @@ 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, 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(), 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)) } } @@ -189,6 +225,50 @@ package final class CodexReviewMCPServer { return .init(result: result, log: log) } + 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 diff --git a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift index c99adfc..945dd26 100644 --- a/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift +++ b/Sources/CodexReviewMCPServer/CodexReviewMCPToolResults.swift @@ -253,24 +253,26 @@ private extension String { } } -private extension CodexReviewAPI.Run.ListItem { +private extension CodexReviewMCP.Tool.ReviewListItemSnapshot { func structuredContent() -> Value { .object([ - "runId": .string(runID.rawValue), - "cwd": .string(cwd), - "targetSummary": .string(targetSummary), - "run": core.structuredRunContent(), - "lifecycle": presentation.structuredLifecycleContent( - core: core, - elapsedSeconds: elapsedSeconds, - cancellable: presentation.isCancellable + "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() }) @@ -278,10 +280,10 @@ private extension CodexReviewAPI.List.Result { } } -private extension CodexReviewAPI.Cancel.Outcome { +private extension CodexReviewMCP.Tool.ReviewCancelSnapshot { func textContent() -> String { - if cancelled { - presentation.cancellation?.message ?? "Review cancelled." + if result.cancelled { + result.presentation.cancellation?.message ?? "Review cancelled." } else { "Review was already finished." } @@ -289,15 +291,17 @@ private extension CodexReviewAPI.Cancel.Outcome { func structuredContent() -> Value { return .object([ - "runId": .string(runID.rawValue), - "cancelled": .bool(cancelled), - "run": core.structuredRunContent(), - "lifecycle": presentation.structuredLifecycleContent( - core: core, + "runId": .string(result.runID.rawValue), + "cancelled": .bool(result.cancelled), + "run": result.core.structuredRunContent(), + "lifecycle": result.presentation.structuredLifecycleContent( + core: result.core, elapsedSeconds: nil, - cancellable: presentation.isCancellable + cancellable: result.presentation.isCancellable + ), + "review": result.core.structuredReviewContent( + resolvedFinalReview: log.finalResult ), - "review": core.structuredReviewContent(), ]) } } diff --git a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift index 53914be..5fc46a6 100644 --- a/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift +++ b/Sources/ReviewUI/ReviewMonitorAddAccountAction.swift @@ -1,34 +1,26 @@ -import AppKit import CodexReviewKit @MainActor enum ReviewMonitorAddAccountAction { static func perform(store: CodexReviewStore) { Task { - do { + await perform(store: store) { try await store.addAccount() - } catch let failure as CodexReviewAuthenticationFailure { - await presentFailureAlert( - title: "Failed to Add Account", - message: failure.localizedDescription - ) - } catch { - preconditionFailure("Unexpected authentication error: \(error)") } } } - 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/SignIn/SignInView.swift b/Sources/ReviewUI/SignIn/SignInView.swift index c54a40c..ae1f878 100644 --- a/Sources/ReviewUI/SignIn/SignInView.swift +++ b/Sources/ReviewUI/SignIn/SignInView.swift @@ -18,10 +18,8 @@ struct SignInView: View { Task { @MainActor in do { try await store.performPrimaryAuthenticationAction() - } catch let failure as CodexReviewAuthenticationFailure { - authenticationFailureMessage = failure.localizedDescription } catch { - preconditionFailure("Unexpected authentication error: \(error)") + authenticationFailureMessage = error.localizedDescription } } } label: { diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift index 1356774..5efc247 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPHTTPServerTests.swift @@ -831,7 +831,20 @@ 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", @@ -903,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.") } } @@ -1219,6 +1235,81 @@ struct CodexReviewMCPHTTPServerTests { } } + @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 attempt = makeHTTPReviewAttempt( attemptID: "attempt-default-cancel-selector", diff --git a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift index c0da252..02e418a 100644 --- a/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift +++ b/Tests/CodexReviewMCPServerTests/CodexReviewMCPServerTests.swift @@ -90,6 +90,45 @@ struct CodexReviewMCPServerTests { } } + @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) @@ -137,6 +176,20 @@ struct CodexReviewMCPServerTests { } } + 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()) diff --git a/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift b/Tests/ReviewUITests/ReviewMonitorAddAccountActionTests.swift new file mode 100644 index 0000000..9068e20 --- /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/scripts/restore-source-mtimes.sh b/scripts/restore-source-mtimes.sh new file mode 100755 index 0000000..f922b72 --- /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 0000000..770e90f --- /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 From 560820872c309cf1c443b0fd1177b11e051945ac Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:45:00 +0900 Subject: [PATCH 62/62] ci(test): shard host test suite --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f2e7f..0b60cc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,13 +81,34 @@ jobs: strategy: fail-fast: false matrix: - shard: - - CodexReviewKitTests - - CodexReviewAppServerTests - - CodexReviewMCPServerTests - - CodexReviewHostTests - - ReviewUITests - - remaining + 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 @@ -106,20 +127,18 @@ jobs: fail-on-cache-miss: true - name: Run package test shard + env: + TEST_FILTER: ${{ matrix.filter }} + TEST_SKIP: ${{ matrix.skip }} run: | - if [[ "${{ matrix.shard }}" == "remaining" ]]; then - swift test \ - --skip-build \ - --build-system swiftbuild \ - --no-parallel \ - --skip 'CodexReviewKitTests|CodexReviewAppServerTests|CodexReviewMCPServerTests|CodexReviewHostTests|ReviewUITests' - else - swift test \ - --skip-build \ - --build-system swiftbuild \ - --no-parallel \ - --filter "${{ matrix.shard }}" - fi + 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)